<?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: Sergei Parfenov</title>
    <description>The latest articles on DEV Community by Sergei Parfenov (@p0rt).</description>
    <link>https://dev.to/p0rt</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%2F157612%2F00065fc9-07d7-47dd-b882-f297a6158dbe.jpeg</url>
      <title>DEV Community: Sergei Parfenov</title>
      <link>https://dev.to/p0rt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/p0rt"/>
    <language>en</language>
    <item>
      <title>Distilling Kimi Into Qwen Doesn't Give You Kimi. It Gives You Qwen With Kimi's Handwriting</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:40:37 +0000</pubDate>
      <link>https://dev.to/p0rt/distilling-kimi-into-qwen-doesnt-give-you-kimi-it-gives-you-qwen-with-kimis-handwriting-284p</link>
      <guid>https://dev.to/p0rt/distilling-kimi-into-qwen-doesnt-give-you-kimi-it-gives-you-qwen-with-kimis-handwriting-284p</guid>
      <description>&lt;p&gt;On July 22, 2026, White House OSTP director Michael Kratsios accused Moonshot AI of distilling Anthropic's Fable 5 to build Kimi K3, and Treasury put sanctions on the table. No technical evidence was published, and researchers immediately pointed out the timeline problem: by K3's launch day, Fable 5 had been publicly reachable for roughly 18 days in total.&lt;/p&gt;

&lt;p&gt;Everyone argued about whether it happened. Almost nobody asked the more useful engineering question: &lt;strong&gt;if it did happen, what would Moonshot actually have received?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the experiment that answers it. In 2025, the Berkeley team behind Sky-T1 fine-tuned a model on long reasoning traces whose &lt;strong&gt;final answers were wrong&lt;/strong&gt;. Accuracy dropped by 3.2 points. They randomized half the numbers inside the reasoning steps: 3.3 points. Then they shuffled the &lt;em&gt;order&lt;/em&gt; of the steps, and performance collapsed. The content of a distill is nearly disposable. The structure is the payload.&lt;/p&gt;

&lt;p&gt;That is the folk model's blind spot. The folk model says: pour a strong model's outputs into an open model, get a model at the strong model's level. What actually crosses the wire is mostly the &lt;em&gt;shape&lt;/em&gt; of the reasoning, and the size of the gain is set by your base model, not by how smart your teacher was.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; A "distill" is a dataset of teacher traces, not a set of weights. Fine-tuning on it reliably transfers output &lt;em&gt;structure&lt;/em&gt; (long chain-of-thought, backtracking, &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; blocks) and only narrowly transfers capability. Evidence: models trained on traces with &lt;strong&gt;wrong answers&lt;/strong&gt; lose about 3.2 points versus correct ones, and randomizing half the numbers in the traces costs 3.3 points on AIME 2024. Real capability gains do exist (DeepSeek's R1 distills beat RL on the same base by 25 points on AIME), but they cost 800k rejection-sampled samples and a full fine-tune, not 8k samples and a LoRA. The headline benchmark jumps you see on Hugging Face are very often eval artifacts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a "distill" actually is
&lt;/h2&gt;

&lt;p&gt;When someone says "I poured a distill into Qwen," they are not moving weights. They are running SFT on a dataset of teacher outputs. There are two distinct channels, and they behave differently:&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;Black-box (traces)&lt;/th&gt;
&lt;th&gt;White-box (logits)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you collect&lt;/td&gt;
&lt;td&gt;Generated text, usually prompt + long CoT + answer&lt;/td&gt;
&lt;td&gt;Full next-token distributions, or hidden states&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loss&lt;/td&gt;
&lt;td&gt;Cross-entropy on the teacher's tokens&lt;/td&gt;
&lt;td&gt;KL between teacher and student distributions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bits per token&lt;/td&gt;
&lt;td&gt;One sampled token&lt;/td&gt;
&lt;td&gt;The whole distribution ("dark knowledge")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs&lt;/td&gt;
&lt;td&gt;API access&lt;/td&gt;
&lt;td&gt;Weights, and enough GPUs to run the teacher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works across model families&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, if tokenizers align&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Anything distilled from a closed API (Claude, GPT, Gemini) is black-box by construction. You cannot get logits out of an inference endpoint. This matters more than it sounds: black-box distillation is the low-bandwidth channel, and it is the only one available in the scenario the White House described.&lt;/p&gt;

&lt;p&gt;The whole current ecosystem is downstream of two decisions made four months apart. In September 2024, OpenAI hid o1's raw chain of thought and explicitly listed competitive advantage among the reasons. In January 2025, DeepSeek shipped R1 with full traces exposed under a permissive license, and within weeks the distill wave existed: s1, LIMO, Sky-T1, and a Hugging Face shelf of "-Distill" repos. Traces are the substrate; whoever exposes them feeds the ecosystem, and hiding them is anti-distillation policy by another name. None of this is exotic in-house either: Google's own Gemini 1.5 technical report states that Flash is online-distilled from the much larger Pro. Every lab does this to its own models. The fight is only ever about doing it to someone else's.&lt;/p&gt;

&lt;p&gt;Kimi is the interesting inverse case. K3 shipped as open weights in late July 2026 at 2.8 trillion parameters, so white-box distillation &lt;em&gt;from&lt;/em&gt; Kimi is legally and technically on the table. The gate is not access, it is the inference bill for generating traces from a model that needs roughly 1.4 TB of fast memory resident before you load any context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that it genuinely works
&lt;/h2&gt;

&lt;p&gt;DeepSeek ran the cleanest public experiment on this, and it is still the strongest pro-distillation datapoint we have.&lt;/p&gt;

&lt;p&gt;They generated about 600k rejection-sampled reasoning traces plus 200k general samples from R1, then ran plain SFT for two epochs on off-the-shelf open bases. No RL on the students at all. Then they asked the obvious control question: what if you skip the teacher and just run large-scale RL on the same base?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Qwen-32B base, three treatments&lt;/th&gt;
&lt;th&gt;AIME 2024&lt;/th&gt;
&lt;th&gt;MATH-500&lt;/th&gt;
&lt;th&gt;GPQA-D&lt;/th&gt;
&lt;th&gt;LiveCodeBench&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QwQ-32B-Preview (reference)&lt;/td&gt;
&lt;td&gt;50.0&lt;/td&gt;
&lt;td&gt;90.6&lt;/td&gt;
&lt;td&gt;54.5&lt;/td&gt;
&lt;td&gt;41.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RL directly on the base, 10k+ steps&lt;/td&gt;
&lt;td&gt;47.0&lt;/td&gt;
&lt;td&gt;91.6&lt;/td&gt;
&lt;td&gt;55.0&lt;/td&gt;
&lt;td&gt;40.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFT on 800k R1 traces&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;57.2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is a 25 point gap on AIME in favor of distillation, against an RL run that cost far more compute. DeepSeek's own conclusion was blunt: distilling a powerful model into a smaller one works, while small models relying on large-scale RL need enormous compute and may still lose.&lt;/p&gt;

&lt;p&gt;This is not just curve-sharpening either. A widely cited ICML/NeurIPS 2025 analysis measured pass@k rather than pass@1 and found the two methods differ in kind. RLVR raises pass@1 while &lt;em&gt;narrowing&lt;/em&gt; the reasoning boundary at large k, because the paths it reinforces were already in the base's sampling distribution. Distillation raises the curve at every k (on Qwen-7B: pass@1 from 28% to 45%, still above 90% at pass@256), meaning genuinely new reasoning patterns entered the model.&lt;/p&gt;

&lt;p&gt;So: yes, capability moves. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that it isn't what you think
&lt;/h2&gt;

&lt;p&gt;Back to the corruption experiment from the intro, with the setup spelled out. The Sky-T1 team first got +40 points on AIME 2024 by fine-tuning Qwen2.5-32B-Instruct on just 17k long-CoT traces distilled from R1. Only then did they start breaking the training data on purpose, and found that only structural damage (shuffling, inserting, deleting steps) actually hurt, while wrong answers and randomized numbers cost ~3 points each. Their conclusion sits in the paper's own title: structure, not content, is what matters.&lt;/p&gt;

&lt;p&gt;Two more datapoints point the same way. s1 hit strong reasoning numbers with 1,000 samples. LIMO used 817. If a thousand examples move a benchmark 40 points, you are not transferring a frontier lab's knowledge in a thousand examples. You are flipping a switch that was already wired.&lt;/p&gt;

&lt;p&gt;This is the same finding that killed the first imitation wave in 2023. Berkeley's "False Promise of Imitating Proprietary LLMs" found crowd workers rated ChatGPT imitators as competitive, while targeted benchmarks showed they closed little to none of the gap: they mimicked style, not factuality. Thinking Machines said the same thing in 2025 about off-policy distillation, that the student learns the teacher's style and confidence without necessarily learning its accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example at 0.01% of the weights
&lt;/h2&gt;

&lt;p&gt;Here is the exact thing the question is usually about, done in public and documented honestly.&lt;/p&gt;

&lt;p&gt;Someone took &lt;code&gt;Qwen3.6-35B-A3B&lt;/code&gt;, generated ~7.8k reasoning traces from Kimi K2.6 via OpenRouter, and ran SFT with Unsloth and LoRA. Attention-only adapters, &lt;code&gt;r=16&lt;/code&gt;, 980 steps, about 21 hours on a single H200. Trainable parameters: 3.44M out of 35.1B. That is 0.01% of the model.&lt;/p&gt;

&lt;p&gt;The model card then does something almost nobody does. It reports evals that undercut the model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark, same pipeline both sides&lt;/th&gt;
&lt;th&gt;Base Qwen3.6-35B-A3B&lt;/th&gt;
&lt;th&gt;Kimi-distill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MATH-500 (0-shot, &lt;code&gt;math_verify&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;47.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPQA Diamond (0-shot CoT)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79.29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;75.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K (8-shot, strict-match)&lt;/td&gt;
&lt;td&gt;64.0&lt;/td&gt;
&lt;td&gt;92.67&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The author's read on that GSM8K number is the important part: the base scoring 64% is implausible for a frontier 35B-A3B, and the likely cause is that the few-shot template never triggers the base's thinking mode. So the +28.67 point "win" measures &lt;em&gt;"my pipeline rewards models that always think,"&lt;/em&gt; not capability. His stated conclusion is that the run provides no evidence the distillation improved raw reasoning over the base. What it does provide is a guarantee: the distill emits &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; blocks regardless of prompt shape, where the base's thinking mode is conditional.&lt;/p&gt;

&lt;p&gt;That is a real, useful property. It is also exactly what "handwriting transplant" means. And note the cost signature: Kimi's traces averaged 2,933 tokens against 849 for a matched Claude Opus 4.7 set, roughly a 2.5x compute multiplier for the same number of rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price list
&lt;/h2&gt;

&lt;p&gt;Dollar figures are illustrative (cloud rates move), but the orders of magnitude are the point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Compute&lt;/th&gt;
&lt;th&gt;What you demonstrably get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;s1-32B (Stanford)&lt;/td&gt;
&lt;td&gt;1,000 curated Gemini traces&lt;/td&gt;
&lt;td&gt;26 min on 16 H100s, ~$25 at cloud rates&lt;/td&gt;
&lt;td&gt;Thinking format + test-time scaling; beats o1-preview on competition math&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K2.6 → Qwen3.6 LoRA&lt;/td&gt;
&lt;td&gt;7.8k K2.6 traces via OpenRouter&lt;/td&gt;
&lt;td&gt;~21 h on one H200&lt;/td&gt;
&lt;td&gt;Unconditional &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; blocks; no measured capability gain over base&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek R1 distills&lt;/td&gt;
&lt;td&gt;800k rejection-sampled samples&lt;/td&gt;
&lt;td&gt;Full SFT, 2 epochs, six bases&lt;/td&gt;
&lt;td&gt;+25 pts on AIME over RL on the same base&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking Machines on-policy&lt;/td&gt;
&lt;td&gt;Teacher-scored student rollouts&lt;/td&gt;
&lt;td&gt;1,800 GPU-hours (their RL baseline: 17,920)&lt;/td&gt;
&lt;td&gt;~70% AIME'24, RL parity at ~10% of the compute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read top to bottom and the pattern is the whole article: the format transplant costs lunch money, the capability transfer costs a training run.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what actually crossed the wire?
&lt;/h2&gt;

&lt;p&gt;Until recently this was unanswerable per output. A December 2025 paper proposes a provenance-tracing framework that scores every sentence a distilled model produces under three models (teacher, original student, distilled student) and sorts it into four buckets: teacher-originated, student-originated, already present in both, and pre-existing but boosted by distillation. In their analysis, teacher-originated actions do appear in unseen test contexts and correlate with correctness, but a large share of what the distilled model emits is student-internal patterns that distillation merely amplified, and not all of that amplification helps.&lt;/p&gt;

&lt;p&gt;One more mechanism worth knowing, because it bounds the cross-family case. Anthropic Fellows work published in Nature this year showed models can transmit behavioral traits through data with no semantic connection to those traits, including through reasoning traces and code. The catch: the effect only appears when teacher and student share the same base model or a behaviorally matched one. Across families it disappears. So a Fable-to-Kimi transfer, if it happened, has only the visible-traces channel open. The spooky hidden channel is not available across architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill
&lt;/h2&gt;

&lt;p&gt;Distillation is not free, and the invoice arrives in three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capacity gap.&lt;/strong&gt; Apple's distillation scaling law found the student's loss improves with teacher strength only up to a point, after which a stronger teacher produces a &lt;em&gt;worse&lt;/em&gt; student. The teacher becomes harder to model, and the student can no longer absorb the gains. Picking the strongest available teacher is not automatically correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution mismatch.&lt;/strong&gt; Off-policy SFT trains the student to navigate states the teacher visits. At inference the student is in its own states, which it never trained for, and errors compound over long chains. This is the structural argument for on-policy distillation, where the student generates and the teacher scores each token. Thinking Machines reported roughly 70% on AIME'24 for 1,800 GPU-hours where their RL baseline needed 17,920, and framed the reason cleanly: RL delivers about O(1) bits of signal per episode, distillation delivers O(N) bits, one per token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting.&lt;/strong&gt; A 2026 paper on post-training with knowledge retention reports that SFT on rejection-sampled Gemini 2.5 Pro responses dropped IFEval by 11.5 points on Llama-3.1-8B-Instruct, and even eroded in-domain reasoning on Qwen3-8B from 46.8% to 41.0%. You are not adding a skill to a static model. You are moving the whole distribution, and things fall out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist: capability transfer or handwriting transplant?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Handwriting only&lt;/th&gt;
&lt;th&gt;Capability actually moves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data scale&lt;/td&gt;
&lt;td&gt;1k to 20k traces&lt;/td&gt;
&lt;td&gt;100k+, rejection-sampled against verified answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you train&lt;/td&gt;
&lt;td&gt;Attention-only LoRA, 0.01% of params&lt;/td&gt;
&lt;td&gt;Full fine-tune, or LoRA over FFN/experts too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teacher gap&lt;/td&gt;
&lt;td&gt;As large as possible&lt;/td&gt;
&lt;td&gt;Inside the capacity-gap range for your student&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;Traces from one domain, eval in another&lt;/td&gt;
&lt;td&gt;Traces drawn from the distribution you'll deploy on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy&lt;/td&gt;
&lt;td&gt;Off-policy SFT only&lt;/td&gt;
&lt;td&gt;On-policy phase after the cold start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eval&lt;/td&gt;
&lt;td&gt;pass@1, one pipeline, base's thinking mode never invoked&lt;/td&gt;
&lt;td&gt;pass@k, identical pipeline, thinking mode forced on both sides, OOD and instruction-following held out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your setup sits mostly in the left column, you did not get a frontier model. You got your base model that now reliably reasons out loud. Ship it if that is what you needed. Just do not put "matches Kimi K2.6" in the model card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to Kimi K3
&lt;/h2&gt;

&lt;p&gt;Numbers first, because most coverage skipped them. In February 2026, Anthropic publicly attributed more than 16 million Claude exchanges across roughly 24,000 fraudulent accounts to coordinated campaigns by Moonshot, DeepSeek, and MiniMax, over 3.4 million of them pinned on Moonshot alone. In June, Anthropic told the US Senate Banking Committee that Alibaba's Qwen lab had run the largest known distillation attack to date. So industrial-scale trace harvesting is on the record as a specific, quantified allegation, months before Kratsios spoke.&lt;/p&gt;

&lt;p&gt;The Fable-to-K3 link is the shaky part. Per press reconstructions of the timeline, Fable 5 launched June 9, was suspended June 12 under export controls, and returned July 1; K3 launched July 16. That is the ~18 days from the intro. K3's pretraining necessarily predates all of it, so the only technically coherent version of the accusation is targeted post-training on Fable-derived traces over an already-trained base. Which is precisely the scenario this article is about: it would buy behavioral transfer in the domains the traces covered, at black-box bandwidth, from under three weeks of harvesting. It would not buy the pretraining run, and pretraining is the part nobody knows how to distill, because the parameter space is specific to each network.&lt;/p&gt;

&lt;p&gt;I wrote earlier about the &lt;a href="https://dev.to/p0rt/how-model-distillation-actually-works-and-what-the-china-distilled-our-model-headlines-really-3o0o"&gt;legal and ToS side of this&lt;/a&gt; and I want to correct my own emphasis: framing distillation primarily as an IP question makes it sound like the technique hands over a copy of the model. It does not. The interesting question was always mechanical, and the mechanical answer constrains the policy answer.&lt;/p&gt;

&lt;p&gt;Caveats on my own sources, since I am asking you to update on them: DeepSeek's numbers are self-reported, the Kimi-distill evals above are single-run and small-sample by the author's own admission, and the White House claim has no published evidence behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question for anyone who has actually run one of these:&lt;/strong&gt; what is the one eval in your harness that would have caught a benchmark jump that was really just your base model's thinking mode failing to trigger? I suspect most of us do not have one, and that is why the Hugging Face leaderboard looks the way it does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2501.12948" rel="noopener noreferrer"&gt;DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via RL&lt;/a&gt; (Tables 5 and 6, distillation vs RL)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2504.13837" rel="noopener noreferrer"&gt;Does RL Really Incentivize Reasoning Capacity in LLMs Beyond the Base Model?&lt;/a&gt; (pass@k; distillation expands the boundary, RLVR narrows it)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2502.07374" rel="noopener noreferrer"&gt;LLMs Can Easily Learn to Reason from Demonstrations: Structure, not content, is what matters!&lt;/a&gt; (the corruption experiments)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2305.15717" rel="noopener noreferrer"&gt;The False Promise of Imitating Proprietary LLMs&lt;/a&gt; (style vs factuality, 2023)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://machinelearning.apple.com/research/distillation-scaling-laws" rel="noopener noreferrer"&gt;Distillation Scaling Laws&lt;/a&gt; (capacity gap)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thinkingmachines.ai/blog/on-policy-distillation/" rel="noopener noreferrer"&gt;On-Policy Distillation&lt;/a&gt; (Thinking Machines Lab)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://arxiv.org/abs/2512.20908" rel="noopener noreferrer"&gt;Where Did This Sentence Come From? Tracing Provenance in LLM Reasoning Distillation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.nature.com/articles/s41586-026-10319-8" rel="noopener noreferrer"&gt;Language models transmit behavioural traits through hidden signals in data&lt;/a&gt; (Nature; &lt;a href="https://arxiv.org/abs/2507.14805" rel="noopener noreferrer"&gt;preprint&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2603.01683" rel="noopener noreferrer"&gt;Surgical Post-Training: Proximal On-Policy Distillation with Knowledge Retention&lt;/a&gt; (forgetting numbers)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/lordx64/Qwen3.6-35B-A3B-Kimi-K2.6-Reasoning-Distilled" rel="noopener noreferrer"&gt;Qwen3.6-35B-A3B-Kimi-K2.6-Reasoning-Distilled model card&lt;/a&gt; (the honest eval section)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2501.19393" rel="noopener noreferrer"&gt;s1: Simple test-time scaling&lt;/a&gt; (1,000 samples, 26 minutes on 16 H100s)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/learning-to-reason-with-llms/" rel="noopener noreferrer"&gt;Learning to reason with LLMs&lt;/a&gt; (OpenAI on hiding o1's raw chain of thought)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2403.05530" rel="noopener noreferrer"&gt;Gemini 1.5 technical report&lt;/a&gt; (Flash is online-distilled from Pro)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.scmp.com/news/us/diplomacy/article/3361510/trump-tech-official-accuses-chinas-moonshot-ai-stealing-anthropic" rel="noopener noreferrer"&gt;Trump tech official accuses China's Moonshot AI of stealing from Anthropic&lt;/a&gt; (SCMP), &lt;a href="https://thenewstack.io/moonshot-fable5-distillation-accusations/" rel="noopener noreferrer"&gt;The New Stack&lt;/a&gt; (the 16M / 24,000 numbers), and &lt;a href="https://glitchwire.com/news/trump-administration-accuses-moonshot-ai-of-distilling-anthropics-fable-escalati/" rel="noopener noreferrer"&gt;Glitchwire&lt;/a&gt; (Moonshot's 3.4M, the June Senate claim about Qwen)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kingy.ai/blog/kimi-k3-fable-5-distillation/" rel="noopener noreferrer"&gt;Kingy.ai's timeline reconstruction&lt;/a&gt; (Fable 5 availability windows vs K3's release)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2026/Jul/16/kimi-k3/" rel="noopener noreferrer"&gt;Kimi K3 launch notes&lt;/a&gt; (Simon Willison)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Found Two Bugs in Zulip. The Maintainers Had Filed Both Two Weeks Earlier.</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:38:24 +0000</pubDate>
      <link>https://dev.to/p0rt/i-found-two-bugs-in-zulip-the-maintainers-had-filed-both-two-weeks-earlier-4mom</link>
      <guid>https://dev.to/p0rt/i-found-two-bugs-in-zulip-the-maintainers-had-filed-both-two-weeks-earlier-4mom</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I went hunting in Zulip's codebase for this challenge and found two real data-corruption bugs in the Slack importer. Solid ones: silent message scrambling during workspace migrations, the kind of bug that costs somebody a re-migration.&lt;/p&gt;

&lt;p&gt;Then I did the thing you are supposed to do before writing a single line of fix. I searched the tracker.&lt;/p&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/zulip/zulip/issues/39650" rel="noopener noreferrer"&gt;#39650&lt;/a&gt;. Opened June 30, by the maintainers themselves: a systematic audit of the Slack import path, roughly a dozen items, confirmed and triaged. My two discoveries were on that list, described more precisely than I would have described them. &lt;strong&gt;I was two weeks late to my own findings.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the story of what happened next, because "what happened next" turned out to be the useful part.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Hunted in Zulip → found 2 real bugs → both already filed by the maintainers (#39650) → the maintainer was already fixing them in an open PR (#39757) → pivoted twice: found an unreported latent twin of the bug class in the newest importer (Microsoft Teams), and claimed the one confirmed item the maintainer's PR did not touch (an unguarded timestamp sort key, which turned out to hide a silent NaN failure mode on top of the two loud ones). Shipped two PRs (&lt;a href="https://github.com/zulip/zulip/pull/39813" rel="noopener noreferrer"&gt;#39813&lt;/a&gt;, &lt;a href="https://github.com/zulip/zulip/pull/39814" rel="noopener noreferrer"&gt;#39814&lt;/a&gt;), each with a test that fails on the old code. Lessons at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Picking the hunting ground
&lt;/h2&gt;

&lt;p&gt;Why Zulip: it is on this challenge's suggested repo list, it is Python, and it has a reputation that makes hunting interesting rather than easy. Near-total backend test coverage, strict mypy, ruthless linting, and a commit discipline of "each commit is a minimal coherent idea". I ran an extended ruff pass over the whole backend just to check the floor. Nothing but style noise. &lt;strong&gt;Lint-level bugs do not survive in this codebase.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which leaves logic-level bugs, and for those you want a subsystem where correctness is hard and the inputs are hostile. Data import is exactly that: long batch jobs over &lt;em&gt;other tools'&lt;/em&gt; export files, arbitrary data quality, and no retry culture, because a workspace migration is a one-shot event for the admin running it. One unhandled edge case on message 31,000 of 50,000 and the whole thing is off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two bugs I "found"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug one: thread state does not survive chunk boundaries.&lt;/strong&gt; Slack messages stream through the converter in chunks of 1,000. The map that routes thread replies to their Zulip topics was allocated &lt;em&gt;inside&lt;/em&gt; the per-chunk function. Thread root in chunk N, replies in chunk N+1: the replies land in an orphan topic literally named &lt;code&gt;"... No channel message"&lt;/code&gt;. The detail that makes it art: the &lt;em&gt;adjacent&lt;/em&gt; cache in the same file was deliberately made module-global, with a comment explaining that it must survive across calls. One cache got the cross-chunk treatment. Its sibling, added later, did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug two: the thread key is truncated to seconds.&lt;/strong&gt; A thread's identity was computed as &lt;code&gt;strftime("%Y/%m/%d %H:%M:%S")&lt;/code&gt; plus the parent's user id. No channel component. No microseconds, even though Slack's raw &lt;code&gt;ts&lt;/code&gt; carries them, as a string, right there in the message. Any bot that posts twice within one second and collects replies on both messages: two distinct threads merged into one topic, conversations interleaved.&lt;/p&gt;

&lt;p&gt;Both bugs are silent. No exceptions, no warnings, just a migrated archive that is quietly wrong.&lt;/p&gt;

&lt;p&gt;I was fairly pleased with myself for about as long as it took to search the tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tracker, and the etiquette call
&lt;/h2&gt;

&lt;p&gt;Finding #39650 stung, then got worse in an instructive way: &lt;a href="https://github.com/PieterCK" rel="noopener noreferrer"&gt;PieterCK&lt;/a&gt;, the maintainer who owns the importers, already had an open PR, &lt;a href="https://github.com/zulip/zulip/pull/39757" rel="noopener noreferrer"&gt;#39757&lt;/a&gt;, "slack_importer: Fix Slack thread conversion bugs". I opened its Files changed tab with a sinking feeling. Both of my thread bugs: being fixed, by the person whose code it is, with more context than I will ever have.&lt;/p&gt;

&lt;p&gt;This challenge has a section called "Smash Bugs, Respectfully", about not adding to maintainer workload. Racing a maintainer's open PR on their own audit items is a textbook way to fail it. So: &lt;strong&gt;stand down on the thread bugs.&lt;/strong&gt; Not negotiable, and honestly not even disappointing once framed correctly, because independently rediscovering two items from a maintainer audit is not wasted work. It is calibration. My nose was pointing at real bugs; it was just pointing at them second.&lt;/p&gt;

&lt;p&gt;If the audited file is picked clean, two moves remain: find what the audit &lt;em&gt;missed&lt;/em&gt; elsewhere, and find what the audit &lt;em&gt;found&lt;/em&gt; but nobody claimed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move one: same disease, newer organ
&lt;/h2&gt;

&lt;p&gt;Audits cover files, but bug classes travel across files, carried by copy-paste and by shared habits. So I took the bug classes from the Slack audit and checked the sibling importers. Mattermost: clean on these classes, different batching helper. Microsoft Teams, the &lt;em&gt;newest&lt;/em&gt; importer in the family: jackpot.&lt;/p&gt;

&lt;p&gt;Its batching generator yielded a list and then called &lt;code&gt;.clear()&lt;/code&gt; on the same object to build the next batch. Run the minimal version yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;batched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# expected: [[0..4], [5..9], [10, 11]]
# actual:   [[10, 11], [10, 11], [10, 11]]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every yielded batch is one shared list. Ten of twelve messages gone, zero exceptions. The bug is latent: Zulip's current caller consumes each batch before advancing, so nothing fires today. It is still a broken contract, because the consumer owns a yielded value, and every future consumer inherits a trap that silently destroys data during a one-shot migration. Nobody had reported it. That became PR &lt;a href="https://github.com/zulip/zulip/pull/39814" rel="noopener noreferrer"&gt;#39814&lt;/a&gt;, and the "why fix a latent bug" argument became [its own submission][LINK: Teams Clear the Lineup post].&lt;/p&gt;

&lt;h2&gt;
  
  
  Move two: the confirmed item nobody took
&lt;/h2&gt;

&lt;p&gt;Back in #39650, one high-impact robustness item sat confirmed and unclaimed: the unguarded timestamp sort key. &lt;code&gt;float(message["ts"])&lt;/code&gt;, used to sort every message of the export and as &lt;code&gt;date_sent&lt;/code&gt;. I checked the maintainer's open PR for it, Ctrl+F in Files changed: &lt;strong&gt;not found.&lt;/strong&gt; Free.&lt;/p&gt;

&lt;p&gt;The item as filed had two failure modes: missing &lt;code&gt;ts&lt;/code&gt; raises &lt;code&gt;KeyError&lt;/code&gt;, garbage &lt;code&gt;ts&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt;, either one aborts an entire import because of one message. Writing the guard surfaced a third mode that nobody had listed, and it is the best souvenir of this whole hunt: &lt;code&gt;"ts": "NaN"&lt;/code&gt;. &lt;code&gt;float("NaN")&lt;/code&gt; parses without complaint. NaN compares as False against everything, which quietly violates the total ordering Timsort assumes, so &lt;code&gt;sorted()&lt;/code&gt; returns an inconsistent order. No crash, no warning, scrambled chronology. &lt;strong&gt;The crash is the lucky failure mode.&lt;/strong&gt; That is why the shipped guard requires &lt;code&gt;math.isfinite&lt;/code&gt;, not just a successful parse. This became PR &lt;a href="https://github.com/zulip/zulip/pull/39813" rel="noopener noreferrer"&gt;#39813&lt;/a&gt; and [its own submission][LINK: Slack Clear the Lineup post].&lt;/p&gt;

&lt;h2&gt;
  
  
  Boring discipline, on purpose
&lt;/h2&gt;

&lt;p&gt;Both fixes went through the same routine. Write the regression test first, roll the source back to &lt;code&gt;upstream/main&lt;/code&gt;, and watch the test fail with the exact predicted error: &lt;code&gt;KeyError: 'ts'&lt;/code&gt; for the Slack guard, &lt;code&gt;AssertionError: 24 != 29&lt;/code&gt; for the Teams aliasing (the batches measurably eat messages). Then restore the fix and run everything: 56/56 on the Slack importer module, 10/10 on Teams, lint and mypy clean, coverage showing no uncovered lines in the touched Slack module. A regression test that never failed against the old code is a test fitted to the fix, not a test of the fix.&lt;/p&gt;

&lt;p&gt;Full disclosure on process: the mechanical part of this hunt (cloning, grepping, running suites) went through AI agent tooling; every call you have read about, from which repo to whose bug not to race to which failure policy to ship, stayed human. Zulip has an explicit AI-use policy for contributions, and both PRs follow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Epilogue: arguing with the robot
&lt;/h2&gt;

&lt;p&gt;For the other track's submission I wired the crash into Sentry and asked Seer, its AI debugger, for a root cause. Credit where due: Seer nailed the diagnosis in seconds, down to quoting the exact poisoned message it pulled from the frame locals. Then it suggested a fix: &lt;code&gt;float(message.get("ts", 0))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That one-liner is the fallback-timestamp option I had already declined in the PR. It patches the missing-&lt;code&gt;ts&lt;/code&gt; mode, leaves &lt;code&gt;ValueError&lt;/code&gt; alive, waves &lt;code&gt;"NaN"&lt;/code&gt; straight through into the sort, and stamps real messages with a 1970 &lt;code&gt;date_sent&lt;/code&gt;. One failure mode out of three, plus fabricated chronology. The tool found the root cause faster than I would have; deciding the failure &lt;em&gt;policy&lt;/em&gt; was still my job. I suspect that division of labor is going to describe a lot of debugging from here on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am taking away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Freshly audited ground is picked clean. Hunt where the code is newest.&lt;/strong&gt; The Slack importer had a dozen filed bugs and zero available ones; the newest importer had an unreported twin waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rediscovery is calibration, not waste.&lt;/strong&gt; Going two-for-two against a maintainer audit told me the method works; it just needs to run earlier or elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Filed" is not "fixed".&lt;/strong&gt; A confirmed, high-impact item sat unclaimed for over three weeks in one of the best-maintained Python codebases around. Trackers are full of these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the open PRs before you race them.&lt;/strong&gt; The most useful contribution I made to the thread bugs was not making one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The crash is the lucky failure mode.&lt;/strong&gt; The loud errors were filed by an audit; the silent NaN mode was not. The bugs that skip the crash are the ones that make it to production, and to your archives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Have you ever arrived two weeks late to your own discovery? And did you file it under wasted effort, or under proof your nose works? I have firmly moved to the second column.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paper trail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The maintainers' audit: &lt;a href="https://github.com/zulip/zulip/issues/39650" rel="noopener noreferrer"&gt;https://github.com/zulip/zulip/issues/39650&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The maintainer's thread-fix PR: &lt;a href="https://github.com/zulip/zulip/pull/39757" rel="noopener noreferrer"&gt;https://github.com/zulip/zulip/pull/39757&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My Slack timestamp guard: &lt;a href="https://github.com/zulip/zulip/pull/39813" rel="noopener noreferrer"&gt;https://github.com/zulip/zulip/pull/39813&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My Teams batching fix: &lt;a href="https://github.com/zulip/zulip/pull/39814" rel="noopener noreferrer"&gt;https://github.com/zulip/zulip/pull/39814&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The challenge's "Smash Bugs, Respectfully" guide: &lt;a href="https://dev.to/opensourcepledge/how-to-respectfully-contribute-to-open-source-cbh"&gt;https://dev.to/opensourcepledge/how-to-respectfully-contribute-to-open-source-cbh&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>This Bug Has Never Fired in Production. I Fixed It Anyway</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:23:33 +0000</pubDate>
      <link>https://dev.to/p0rt/this-bug-has-never-fired-in-production-i-fixed-it-anyway-ga6</link>
      <guid>https://dev.to/p0rt/this-bug-has-never-fired-in-production-i-fixed-it-anyway-ga6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run this and predict the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;

&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;batched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: &lt;code&gt;[[0..4], [5..9], [10, 11]]&lt;/code&gt;. Actual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three elements are &lt;strong&gt;the same list object&lt;/strong&gt;. Ten of twelve messages are gone, no exception raised. This exact pattern was sitting in Zulip's Microsoft Teams importer, and the interesting part is not the fix (one line). It is that the bug has never fired in production, and I think that made it &lt;em&gt;more&lt;/em&gt; worth fixing, not less.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;code&gt;get_batched_export_message_data()&lt;/code&gt; in Zulip's Teams importer yielded a batch list, then cleared and refilled the same object for the next batch. Safe for the current lazy consumer, silently destructive for any consumer that retains batches (starting with &lt;code&gt;list(...)&lt;/code&gt;). My fix (&lt;a href="https://github.com/zulip/zulip/pull/39814" rel="noopener noreferrer"&gt;zulip/zulip#39814&lt;/a&gt;) hands ownership of each yielded list to the consumer, plus a regression test that materializes the generator and fails on the old code with a concrete data-loss assertion: &lt;code&gt;24 != 29&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/zulip/zulip" rel="noopener noreferrer"&gt;Zulip&lt;/a&gt; is an open-source team chat server (Django/Python, ~25k stars) with a famously strict engineering bar: near-total backend coverage, strict mypy, "each commit is a minimal coherent idea".&lt;/p&gt;

&lt;p&gt;The Microsoft Teams importer in &lt;code&gt;zerver/data_import/&lt;/code&gt; is the newest member of Zulip's data-import family: it converts Teams export files into Zulip's format, reading messages and yielding them in batches of &lt;code&gt;chunk_size&lt;/code&gt; for downstream processing. Newest code, fewest eyes: that is exactly where I went hunting, after discovering that the older Slack importer had just been through a full maintainer audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The old batching generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MicrosoftTeamsFieldsT&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;message_data_paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_data_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batched_messages&lt;/span&gt;
            &lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the bug
&lt;/span&gt;        &lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batched_messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;yield&lt;/code&gt; suspends the generator, the consumer processes the batch, and on the next &lt;code&gt;next()&lt;/code&gt; the generator wakes up and calls &lt;code&gt;.clear()&lt;/code&gt; on &lt;strong&gt;the object it just handed out&lt;/strong&gt;. Every yielded batch is one shared list in memory.&lt;/p&gt;

&lt;p&gt;This is a violation of an implicit contract: &lt;strong&gt;the consumer owns a yielded value.&lt;/strong&gt; As long as consumption is strictly lazy (process each batch fully before advancing) nothing visible happens, which is why the current Zulip caller works. But the moment any consumer &lt;em&gt;retains&lt;/em&gt; batches, the simplest being &lt;code&gt;list(generator)&lt;/code&gt;, it gets N references to a single object containing only the final batch's contents. Data is not lost loudly; it is silently replaced. In the importer's test dataset, the total message count collapses from 29 to 24 with zero exceptions.&lt;/p&gt;

&lt;p&gt;The standard library agrees on the contract, by the way: &lt;code&gt;itertools.batched&lt;/code&gt; allocates a fresh tuple per batch. So does every batching recipe in the itertools docs. &lt;code&gt;clear()&lt;/code&gt;-and-refill looks like a memory optimization; what it actually optimizes away is correctness under any future change to the consumer: buffering, retries, parallel prefetch, or a colleague writing &lt;code&gt;list(...)&lt;/code&gt; in a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;strong&gt;&lt;a href="https://github.com/zulip/zulip/pull/39814" rel="noopener noreferrer"&gt;zulip/zulip#39814&lt;/a&gt;&lt;/strong&gt; (branch &lt;code&gt;P0rt:teams-batch-aliasing&lt;/code&gt;, single commit, +21/-1).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batched_messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batched_messages&lt;/span&gt;
    &lt;span class="c1"&gt;# Start a new list rather than clearing the yielded one;
&lt;/span&gt;    &lt;span class="c1"&gt;# the consumer owns the yielded list, and mutating it here
&lt;/span&gt;    &lt;span class="c1"&gt;# would corrupt every batch if the generator is materialized
&lt;/span&gt;    &lt;span class="c1"&gt;# (e.g. with list(...)) or batches are retained across
&lt;/span&gt;    &lt;span class="c1"&gt;# iterations.
&lt;/span&gt;    &lt;span class="n"&gt;batched_messages&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;One line of code, six lines of comment. That ratio is deliberate: the fix is trivial, but the &lt;em&gt;reason&lt;/em&gt; it must stay this way is not visible from the code, and the whole failure mode exists because a past "optimization" looked equally trivial.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The test asserts the contract, not the current caller.&lt;/strong&gt; I extended the existing &lt;code&gt;test_get_batched_export_message_data&lt;/code&gt; to materialize the generator with &lt;code&gt;list(...)&lt;/code&gt; and then check two things: the total message count across all batches, and the exact flattened sequence of message IDs against the sorted source files. That second assertion is the important one; it catches loss, duplication, and reordering in a single check, so the test defends the ownership contract rather than one symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Red phase before green phase.&lt;/strong&gt; With the source file rolled back to &lt;code&gt;upstream/main&lt;/code&gt; and the new test kept, the run fails with &lt;code&gt;AssertionError: 24 != 29&lt;/code&gt;: the aliasing measurably eats messages. With the fix restored, the full module passes: &lt;code&gt;./tools/test-backend zerver.tests.test_microsoft_teams_importer&lt;/code&gt;, 10/10. &lt;code&gt;./tools/lint&lt;/code&gt; and &lt;code&gt;./tools/run-mypy&lt;/code&gt; clean on the branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why fix a bug that never fires?&lt;/strong&gt; Because "latent" describes the caller, not the function. The function's contract is broken today; the current caller just happens not to lean on the broken part. Every future consumer inherits a trap that produces silent data corruption during a one-shot, high-stakes operation (a workspace migration is not a request you can retry). The cost of the fix is one allocation per batch. The cost of not fixing it is a debugging session that starts with "the import succeeded but a fifth of the messages are missing", which is close to the worst bug report a data migration can generate.&lt;/p&gt;




&lt;p&gt;I keep coming back to one framing from this fix: &lt;strong&gt;tests that only cover your current callers are tests of an implementation; tests that cover the contract are tests of the function.&lt;/strong&gt; The first kind rots the moment anyone new calls your code. Where do you draw that line in your own test suites: do you test what the function promises, or what today's callers happen to need?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Bug That Crashes Your Import Is the Lucky One</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:43:45 +0000</pubDate>
      <link>https://dev.to/p0rt/the-bug-that-crashes-your-import-is-the-lucky-one-25of</link>
      <guid>https://dev.to/p0rt/the-bug-that-crashes-your-import-is-the-lucky-one-25of</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You are migrating a 50,000-message Slack workspace to Zulip. Somewhere around message 31,000 the import dies with &lt;code&gt;KeyError: 'ts'&lt;/code&gt;. Annoying, but here is the uncomfortable part: &lt;strong&gt;that is the lucky outcome.&lt;/strong&gt; The unlucky one is &lt;code&gt;"ts": "NaN"&lt;/code&gt;, where nothing dies, nothing warns, and your company's message history quietly comes out in the wrong order.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Zulip's Slack importer used &lt;code&gt;float(message["ts"])&lt;/code&gt; unguarded, both as a sort key and as &lt;code&gt;date_sent&lt;/code&gt;. One message with a missing or malformed &lt;code&gt;ts&lt;/code&gt; aborted the entire import; a non-finite value like &lt;code&gt;"NaN"&lt;/code&gt; did not even raise, it silently broke the sort. My fix (&lt;a href="https://github.com/zulip/zulip/pull/39813" rel="noopener noreferrer"&gt;zulip/zulip#39813&lt;/a&gt;) skips such messages with a warning and requires &lt;code&gt;ts&lt;/code&gt; to parse to a &lt;em&gt;finite&lt;/em&gt; float via &lt;code&gt;math.isfinite&lt;/code&gt;. The regression test fails with &lt;code&gt;KeyError: 'ts'&lt;/code&gt; on the old code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/zulip/zulip" rel="noopener noreferrer"&gt;Zulip&lt;/a&gt; is an open-source team chat server (Django/Python, ~25k stars) with an unusually strict engineering culture: near-total backend test coverage, strict mypy, and a commit discipline of "each commit is a minimal coherent idea".&lt;/p&gt;

&lt;p&gt;The code I touched lives in &lt;code&gt;zerver/data_import/&lt;/code&gt;: the subsystem that converts exports from Slack, Microsoft Teams, and Mattermost into Zulip's format. This subsystem has one property that should shape every line in it: &lt;strong&gt;the input is another tool's output.&lt;/strong&gt; Import is a long batch process over data of arbitrary quality, and the admin running the migration has no way to "fix" what Slack's export tool produced. A pipeline that dies on record 31,207 of 50,000 is strictly worse than one that skips record 31,207 with a warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;get_messages_iterator()&lt;/code&gt; in &lt;code&gt;zerver/data_import/slack.py&lt;/code&gt; streams every message of the export, sorting each day's messages by timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages_for_one_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;get_timestamp_from_message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where the sort key was simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_timestamp_from_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZerverFieldsT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line has &lt;strong&gt;three distinct failure modes&lt;/strong&gt;, all reproduced during validation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;ts&lt;/code&gt; is missing.&lt;/strong&gt; &lt;code&gt;KeyError: 'ts'&lt;/code&gt; straight out of &lt;code&gt;sorted(...)&lt;/code&gt;. The whole import aborts because of one message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zerver/data_import/slack.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;911&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;get_messages_iterator&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages_for_one_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;get_timestamp_from_message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zerver/data_import/slack.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;1461&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;get_timestamp_from_message&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;ts&lt;/code&gt; is garbage.&lt;/strong&gt; &lt;code&gt;"not-a-number"&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt;. Same total abort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;ts&lt;/code&gt; is &lt;code&gt;"NaN"&lt;/code&gt;.&lt;/strong&gt; The nasty one. &lt;code&gt;float("NaN")&lt;/code&gt; is a perfectly valid parse, so nothing raises. But NaN is incomparable (&lt;code&gt;NaN &amp;lt; x&lt;/code&gt; and &lt;code&gt;x &amp;lt; NaN&lt;/code&gt; are both False), which violates the total ordering Timsort assumes, so &lt;code&gt;sorted()&lt;/code&gt; silently returns an inconsistent order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1434139102.000002&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1434139101.000001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; ['1434139102.000002', 'NaN', '1434139101.000001']   # not sorted, no error
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No exception, no warning. Just a migrated archive with scrambled chronology. Crashing is the lucky case; this is the case that costs you a re-migration three weeks later when someone notices the history reads wrong.&lt;/p&gt;

&lt;p&gt;One honesty note: I did not discover this failure class from zero. The Zulip maintainers' own audit issue (&lt;a href="https://github.com/zulip/zulip/issues/39650" rel="noopener noreferrer"&gt;#39650&lt;/a&gt;) flags the unguarded timestamp sort key as one of the two highest-impact robustness items, and it was unclaimed when I picked it up. What I brought is the implementation, the non-finite analysis, and the test that pins the behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR: &lt;strong&gt;&lt;a href="https://github.com/zulip/zulip/pull/39813" rel="noopener noreferrer"&gt;zulip/zulip#39813&lt;/a&gt;&lt;/strong&gt; (branch &lt;code&gt;P0rt:slack-ts-guard&lt;/code&gt;, single commit, +54/-0 across the module and its test file).&lt;/p&gt;

&lt;p&gt;The fix is a predicate plus a guard, following the skip-with-warning pattern that already exists two lines above for other unimportable messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;message_has_valid_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZerverFieldsT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Whether `ts` is present and parses to a finite float.

    `get_timestamp_from_message` is used both as a sort key and for
    `date_sent`, so a missing or malformed `ts` on a single message
    would otherwise abort the entire import — and a non-finite value
    like &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NaN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; would not even raise, instead silently producing an
    inconsistent sort order.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;message_has_valid_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Skipping Slack message with invalid ts %r in %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;message_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detail that matters: &lt;code&gt;math.isfinite&lt;/code&gt;, not a bare try/except around &lt;code&gt;float()&lt;/code&gt;. A naive "does it parse" check would have fixed the two loud failure modes and waved the silent one straight through.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The test had to fail first.&lt;/strong&gt; I wrote &lt;code&gt;test_get_messages_iterator_skips_invalid_timestamps&lt;/code&gt;: a temp directory with one day of Slack export containing five messages, two valid (deliberately in reverse chronological order), one with no &lt;code&gt;ts&lt;/code&gt;, one with &lt;code&gt;"not-a-number"&lt;/code&gt;, one with &lt;code&gt;"NaN"&lt;/code&gt;. Then I rolled the source file back to &lt;code&gt;upstream/main&lt;/code&gt;, kept the test, and ran it: &lt;code&gt;KeyError: 'ts'&lt;/code&gt;, exactly the predicted crash. Restored the fix: the test asserts that exactly the two valid messages survive, in correct order, with exactly three warnings logged. A regression test that never failed against the old code is a test fitted to the fix, not a test of the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full-suite validation in a provisioned dev environment&lt;/strong&gt;, not just the happy path: &lt;code&gt;./tools/test-backend zerver.tests.test_slack_importer&lt;/code&gt; (56/56 passing), &lt;code&gt;./tools/lint&lt;/code&gt; and &lt;code&gt;./tools/run-mypy&lt;/code&gt; clean, and &lt;code&gt;test-backend --coverage&lt;/code&gt; showing zero uncovered lines in &lt;code&gt;zerver/data_import/slack.py&lt;/code&gt; after the change, including the new guard path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One decision I explicitly left open for reviewers:&lt;/strong&gt; skipping a message with a broken &lt;code&gt;ts&lt;/code&gt; versus synthesizing a fallback timestamp for it. Skipping loses the message but keeps the archive honest; a synthetic timestamp keeps the message but fabricates chronology. I went with skip-plus-warning because it matches the file's existing conventions, and flagged the tradeoff in the PR instead of pretending it does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;I instrumented the demo with &lt;code&gt;sentry-sdk&lt;/code&gt; (&lt;code&gt;traces_sample_rate=1.0&lt;/code&gt;, environment &lt;code&gt;bugsmash-demo&lt;/code&gt;) and ran the exact same poisoned export twice: once with &lt;code&gt;zerver/data_import/slack.py&lt;/code&gt; rolled back to &lt;code&gt;upstream/main&lt;/code&gt;, once with the fix in place. Same branch, same data, one file different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the fix.&lt;/strong&gt; The import span dies with &lt;code&gt;KeyError: 'ts'&lt;/code&gt;, attached right on the trace, status &lt;code&gt;internal_error&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8pcqvur59p1mf059wvh.jpeg" 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%2Fx8pcqvur59p1mf059wvh.jpeg" alt="Sentry trace of the failing import: KeyError 'ts' on the slack_import_demo span" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drilling into the issue gives the full stacktrace, pointing exactly where this PR points: &lt;code&gt;get_messages_iterator&lt;/code&gt; -&amp;gt; &lt;code&gt;get_timestamp_from_message&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fakvf3l2ahf92zqrzoifx.jpeg" 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%2Fakvf3l2ahf92zqrzoifx.jpeg" alt="Sentry issue PYTHON-DJANGO-2: KeyError 'ts' with the stacktrace into get_messages_iterator" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And Seer's root-cause analysis of that issue. The diagnosis is spot on, down to quoting the exact poisoned message it pulled from the frame locals (&lt;code&gt;{"channel_name": "general", "text": "no ts field"}&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlcwa0dmswm1bal58ejc.jpeg" 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%2Fzlcwa0dmswm1bal58ejc.jpeg" alt="Seer agent root cause and suggested fix for the KeyError issue" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One detail worth being honest about, precisely because a Sentry engineer judges this category: Seer's suggested one-liner, &lt;code&gt;float(message.get("ts", 0))&lt;/code&gt;, is the fallback-timestamp option I explicitly declined in the PR. It fixes the missing-&lt;code&gt;ts&lt;/code&gt; mode, but leaves the &lt;code&gt;ValueError&lt;/code&gt; mode alive, waves &lt;code&gt;"NaN"&lt;/code&gt; straight through into the sort, and stamps real messages with a 1970 &lt;code&gt;date_sent&lt;/code&gt;. Its closing advice, though (log a warning and investigate the upstream data quality), is exactly what the shipped fix does. Reading Seer's output critically instead of pasting its patch is, I would argue, the intended use of the tool: it found the root cause in seconds; deciding the failure policy stayed my job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the fix.&lt;/strong&gt; Same export, same transaction: zero issues, and the three skipped messages surface as three warning-level logs on the trace instead of one fatal:&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%2Fppcp8awxhl2qm1ioxn00.jpeg" 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%2Fppcp8awxhl2qm1ioxn00.jpeg" alt="Sentry trace of the fixed import: 0 issues, 3 warning logs, import completed" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That pair of traces is the whole fix, told by monitoring: the same bad input, downgraded from an unhandled exception that kills a migration to three structured warnings on a completed run. (A pedantic footnote for anyone reading the trace metadata: both runs report release &lt;code&gt;c0b5d8c&lt;/code&gt; because the red run rolled back only the module file, not the branch HEAD.)&lt;/p&gt;




&lt;p&gt;The uncomfortable takeaway from failure mode 3: &lt;strong&gt;"does it crash" is a terrible proxy for "is it correct"&lt;/strong&gt;, and the failure modes that skip the crash are the ones that survive into production. Which raises the question I left for the reviewers, and now for you: for a message that is otherwise fine but has a broken timestamp, would you skip it or synthesize a fallback &lt;code&gt;date_sent&lt;/code&gt;? I picked skip. Convince me otherwise in the comments.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Nothing Was Broken. The Report Still Didn't Arrive.</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Sun, 26 Jul 2026 13:50:33 +0000</pubDate>
      <link>https://dev.to/p0rt/nothing-was-broken-the-report-still-didnt-arrive-k29</link>
      <guid>https://dev.to/p0rt/nothing-was-broken-the-report-still-didnt-arrive-k29</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On July 24 at 09:00 Berlin time, the doctors group did not get its daily digest. It got the first half of one, unfinished. Nobody noticed.&lt;/p&gt;

&lt;p&gt;The failure was recorded correctly, in a state file that nothing reads. The job's delivery mode was &lt;code&gt;none&lt;/code&gt;. The fallback alert channel had been dead since June 13, for reasons that turn out to be bug four. So the run failed, the record was written, and the information stopped there.&lt;/p&gt;

&lt;p&gt;Here is how our team actually learns that something broke. Different job, same pipeline:&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%2F4bti84sqh0ssrzaxztbk.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%2F4bti84sqh0ssrzaxztbk.png" alt="Telegram message from the bot reporting a failed cron job, with a doctor replying and tagging the CTO" width="799" height="410"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The failure the team actually saw: a doctor's scheduled job died and she had to tag the CTO in chat. This is what «silent» pipelines look like when they finally speak.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Some context, since everything below assumes it. Symptomato is a telehealth service: patients describe symptoms in a chat, doctors answer them from a helpdesk. Sympy is the agent that works the seam between the two — on a schedule it reads the doctors' inbox and tells them, in Telegram, which conversations need a human today. Nobody watches it run. That is the point of it, and it is also why a broken run can go unnoticed for six weeks.&lt;/p&gt;

&lt;p&gt;So I went looking for the bug behind the missing digest. That is the uncomfortable part: there wasn't one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; an agent run failed with no defective component anywhere in the path. That non-incident triggered a full audit of the pipeline, which turned up four bugs in our own code that nobody had ever seen fail. All four have the same shape: a function that does not know something reports a value instead of admitting it. This post is those four fixes, plus the instrumentation that makes this failure class visible, plus a deliberate decision to record none of the message content while doing it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;Under the hood: a self-hosted agent runtime with a job scheduler, a tool plugin we wrote on top of &lt;a href="https://www.chatwoot.com/" rel="noopener noreferrer"&gt;Chatwoot&lt;/a&gt; (the helpdesk our doctors work in), a set of host-side Python cron scripts, and Telegram as the delivery channel. Three scheduled jobs do the boring work: a morning digest of conversations that need attention, pings when a paid consultation goes 24 hours without a doctor reply, inbox prechecks.&lt;/p&gt;

&lt;p&gt;One constraint shapes everything below: patient conversations contain medical text. Any observability we add has to work without recording message bodies.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The incident with no bug in it
&lt;/h3&gt;

&lt;p&gt;The digest agent sent its first Telegram message, then decided to finalize the digest by editing that message instead of sending a second one. The message tool requires a recipient field even for an edit. The edit call did not have one. The tool rejected it, the turn ended, the scheduler marked the run as &lt;code&gt;error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Walk that path again and look for the defect. The tool validated its input exactly as its schema says. The scheduler recorded the failure exactly as designed. The model picked a legal tool sequence that the prompt never forbade. Every component behaved to spec, and the doctors still had no digest.&lt;/p&gt;

&lt;p&gt;This is the failure mode that makes agent pipelines different: the execution path is chosen at runtime by a model, so "correct components" and "correct behavior" stop being the same claim. Yesterday the same job sent one message and worked. Today it chose send-then-edit and did not. There is no line of code you can point at, and no test that fails, because nothing is deterministic enough to fail.&lt;/p&gt;

&lt;p&gt;Once instrumented, the same failure looks like this. The capture is from the replay run — the July 24 failure itself predates the instrumentation — and it needed no code changes at the failure site:&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%2F4kteyjglaz75wbq0fz3g.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%2F4kteyjglaz75wbq0fz3g.png" alt="Sentry issue detail showing ToolInputError with raw params and a breadcrumb trail ending in a Telegram API call" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The anchor bug, captured automatically: the model chose «send, then edit», and the edit call has no &lt;code&gt;to&lt;/code&gt;. The tool that failed lives in the runtime, not in our code — we never instrumented it; the error was scraped from the gateway's ERROR log lines and turned into an issue.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcinm2e833uayio13gru2.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%2Fcinm2e833uayio13gru2.png" alt="Sentry Seer panel reconstructing the root cause of the issue from breadcrumbs" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Seer reconstructs the failure from breadcrumbs alone — no prompts, no message bodies were ever recorded.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The four bugs the audit did find
&lt;/h3&gt;

&lt;p&gt;A missing digest with no defect in it is a bad place to stop, so I audited the pipeline properly: the tool plugin, the scheduler jobs, the host scripts, the existing telemetry extensions. It came back with a list. Four items on that list turned out to be the same bug wearing different clothes, and I only saw the pattern once I wrote them down next to each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The tool reports a conversation status it never looked up.&lt;/strong&gt; &lt;code&gt;getConversationSummary&lt;/code&gt; returns &lt;code&gt;status: "open"&lt;/code&gt; as a literal, for every conversation, always. The real status sits in the API response one method below. So when the agent asks "is this conversation still open", it is told yes, unconditionally. Every judgment the model makes about whether to act on a conversation rests on a constant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The tool returns page one and calls it the inbox.&lt;/strong&gt; &lt;code&gt;listConversationsByInbox&lt;/code&gt; fetches &lt;code&gt;page=1&lt;/code&gt; and returns the payload with no &lt;code&gt;all_count&lt;/code&gt; check and no truncation marker. Chatwoot paginates at 25. Our host-side Python script got this right and loops until the count matches; the agent tool never did. So on any day with more than 25 open conversations, the model receives 25 and narrates them as the full picture, which is exactly the sort of confident summary you cannot catch by reading the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "The data isn't there yet" gets cached as a fact.&lt;/strong&gt; &lt;code&gt;tariff_from_triage_note&lt;/code&gt; returns &lt;code&gt;"unknown"&lt;/code&gt; for two very different situations: the note is unparseable, and the note has not been posted yet. The caller caches the result unconditionally, and &lt;code&gt;"unknown"&lt;/code&gt; is truthy, so the next run short-circuits on the cache and never looks again. A patient whose chat is scanned in the window before the backend posts its triage note is marked &lt;code&gt;unknown&lt;/code&gt; permanently, which means the 24-hour ping that exists specifically for paid one-time consultations never fires for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The error handler reports failures through the channel that just failed.&lt;/strong&gt; Our host digest script calls a Telegram helper with no error handling, catches the resulting exception, and then tries to report that exception with the same helper, which raises again. Since June 13 the script has ended in a double traceback — 128 of them in the log — and every one of those runs paid for a model call before crashing. Next to it, the monitor script ends its send with &lt;code&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt;, so a failed alert is indistinguishable from a delivered one anywhere in the system.&lt;/p&gt;

&lt;p&gt;Now the shape. In every one of these, something the system does not know is represented as something it does know. Unknown status becomes "open". Twenty-five of thirty becomes "the inbox". A missing note becomes a tariff value, cached forever. A failed alert becomes a successful one. Three of the four never raise at all; the fourth raises twice a day into a log nobody reads, which comes to the same thing. All of them produce plausible output. And the incident that started the audit is the same thing one level up: a failed run represented as nothing at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The status bug is the smallest and my favorite, because the correct value was already in scope. The whole thing is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formatted&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;status&lt;/code&gt; is a literal. The real value sits in the conversation payload that the method one level below already fetches — the fix is to read it from there instead of asserting it.&lt;/p&gt;

&lt;p&gt;Pagination was ported from the host script that already did it right, plus an explicit truncation marker on message reads, so a partial conversation announces itself instead of passing as complete.&lt;/p&gt;

&lt;p&gt;The tariff cache stops recording ignorance as knowledge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before
&lt;/span&gt;&lt;span class="n"&gt;tariff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tariff_from_triage_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cw_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tariff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tariff&lt;/span&gt;          &lt;span class="c1"&gt;# cache: note content is immutable
&lt;/span&gt;
&lt;span class="c1"&gt;# after
&lt;/span&gt;&lt;span class="n"&gt;tariff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tariff_from_triage_note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cw_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tariff&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;# "not posted yet" is not a value
&lt;/span&gt;    &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tariff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tariff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not distinguish the two «unknown»s either; it stops trusting them. An unparseable note now costs a re-read every run instead of being wrong forever, which is the trade I want.&lt;/p&gt;

&lt;p&gt;And the alerting path: the Telegram helper handles its own failures and logs them, the error handler no longer depends on the channel that just failed, and chunking — a fifth thing I fixed while I was in there — splits outside HTML tags instead of through them.&lt;/p&gt;

&lt;p&gt;One thing I am deliberately not calling a fix: the prompt line telling the digest job to send once and never edit. It patches today's symptom and it took ten seconds, but it is an instruction to a stochastic system, not a repair. The actual answer to the opening incident is not in the prompt. It is that a failed run now reaches someone, instead of a file nothing reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Every fix went red before it went green. The pagination and tariff bugs got standalone repro scripts against local mocks, with the buggy implementation copied verbatim, so the failure is demonstrated rather than argued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open conversations in inbox:      30
agent check_inbox list_open sees: 25  (ids 1..25)
host precheck list_open sees:     30  (ids 1..30)
=&amp;gt; 5 conversations invisible to the LLM agent, no truncation signal returned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run 1 (triage in progress): tariff='unknown'  cached={'tariff': 'unknown'}
run 2+ (note now present):  tariff='unknown'  (cache short-circuits, note never re-read)
24h ping fires: False   (correct behaviour would be: True)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the digest incident itself, the replay went into a private test group rather than the doctors group, running the same job with the same shape of payload:&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%2Fn2ytamrvixzxl9y01jcl.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%2Fn2ytamrvixzxl9y01jcl.png" alt="Telegram test group showing two unedited RED demo messages and one GREEN demo message" width="800" height="231"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;RED replay: the agent sends, then tries to edit — the edit dies, the message stays raw. GREEN: same job, one send, no edit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The RED runs are the interesting ones. The message is still sitting there in its raw, pre-edit state, which is precisely how this failed in production: not with an absence, but with a half-finished artifact that looks close enough to a real digest to be skimmed past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;The instrumentation is the other half of the fix, because the four bugs above are the ones I found. The category of "silent wrong answer" is not exhausted by an audit, so the pipeline needs to be able to report on itself. Three independent sources now feed one stream: errors scraped from the runtime's own error log lines, failures inside our agent tools, and cron monitors that stop hearing from a job.&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%2Fqvneft97e92hjmcpe43w.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%2Fqvneft97e92hjmcpe43w.png" alt="Sentry issue stream with three numbered issues: a core tool error, an agent tool failure and a cron monitor failure" width="800" height="445"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three different origins, one stream: (1) a core tool error scraped from the runtime's ERROR log lines, (2) a failure inside one of our own agent tools, (3) a cron monitor that stopped hearing from its job. The unnumbered rows are the same machinery catching unrelated faults.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When one of our tools fails, the HTTP call that caused it arrives attached, which turns "the agent said something odd this morning" into a five-second read:&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%2Fr17a9ly5g0dzcc54vnop.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%2Fr17a9ly5g0dzcc54vnop.png" alt="Sentry breadcrumbs showing an agent tool failure and the Chatwoot HTTP request that caused it" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An agent tool failure with the exact HTTP call that caused it — attached automatically as breadcrumbs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every tool call is now a span with the attributes that matter for this pipeline, and none of the attributes that would put patient text into a third-party system:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhy2scicwp8l8eizu7dt9.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%2Fhy2scicwp8l8eizu7dt9.png" alt="Attributes tab of a gen_ai.execute_tool span in Sentry showing tool name, action and conversation id" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every agent tool call is a span: tool name, action, numeric conversation id, latency, error status. Nothing in that list is message content — that is the whole design.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The tool's own Input and Output tabs are where the arguments and the result would sit. They are empty, and that is deliberate:&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%2Fg6fe7c9ftqr23vjhvy2b.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%2Fg6fe7c9ftqr23vjhvy2b.png" alt="Sentry AI tab showing the trace as a timeline of model calls and tool calls, with an empty Input tab" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same run in Sentry's AI view — model calls and tool calls in sequence, with the selected tool's Input tab empty: arguments and responses are never recorded (PHI).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Model calls get the same treatment, including the ones nobody is watching, which for this agent is most of them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwoow5569wsgwu1oqufj.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%2Fkwoow5569wsgwu1oqufj.png" alt="Attributes of a gen_ai.chat span in Sentry showing model, provider, conversation id and timings" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One span per model call — model, provider, conversation id, latency, time to first byte. Emitted for background cron runs too, which is where this agent does most of its work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And they correlate, which is what makes an agent run debuggable at all: the model call, the tool call it triggered, and the outbound HTTP request that tool made, in one waterfall.&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%2Fdbsvyrvej84yfyj92cfm.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%2Fdbsvyrvej84yfyj92cfm.png" alt="Sentry trace waterfall of one agent run: a model call, a tool call and the outbound HTTP request" width="800" height="293"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One agent run, one trace: the model call, the tool call it triggered, and the outbound HTTP request — correlated through the runtime's own trace context.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Token usage and cost land at run level, keyed by conversation:&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%2Fi54y821df9gztngwoo1c.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%2Fi54y821df9gztngwoo1c.png" alt="Attributes of a gen_ai.invoke_agent span showing token usage and dollar cost per agent run" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Run-level usage: tokens in/out and dollar cost per agent run, keyed by conversation id — the cheapest possible answer to «what is this agent costing us».&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then the piece that speaks to the failure mode underneath the opening incident. Error monitoring catches runs that fail. It cannot catch runs that stop happening, and it cannot catch a delivery that quietly goes nowhere. Cron monitors turn a schedule into an expectation, and check-ins into evidence. (The red one below is the host digest script from bug four, not the doctors' digest from the opening: two different jobs, one failure mode.)&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%2Fu8e4n2vpkwt65rynbcye.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%2Fu8e4n2vpkwt65rynbcye.png" alt="Sentry cron monitors list with one failing and two healthy monitors" width="799" height="262"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three host crons, monitored from code (no UI setup). The red one is the script from bug four — it has been failing since June 13, and the monitor is the first thing in six weeks to say so out loud.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzt9z06y0a8udh031k1o.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%2Fvzt9z06y0a8udh031k1o.png" alt="Sentry cron monitor detail page showing missed and failed check-ins and an ongoing issue" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Missed, missed, failed. A job that stops running produces no error at all, and a job that fails into a log nobody reads produces none that anyone sees — a cron monitor turns both into the same alert.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Six weeks of that script crashing on schedule would have been one alert on day one.&lt;/p&gt;

&lt;p&gt;On the PHI side, the deliberate choice: prompt and response recording is off. The safe list is model id, provider, token counts, cost, durations, tool name and action, numeric conversation ids, session key, job id, outcome. Anything string-valued that comes from message content goes through redaction or does not get sent. The result is a monitoring stack that can tell me a tool failed, which tool, on which conversation id, how long it took and what it cost, and cannot tell me or anyone else what the patient wrote.&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%2F4k153d225mdzezbtwdtz.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%2F4k153d225mdzezbtwdtz.png" alt="Sentry AI transcript tab stating that the conversation's messages were not captured" width="799" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Sentry offers a full conversation transcript for AI traces — and for this agent it is empty by construction: «This conversation's messages weren't captured».&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That empty transcript is not a gap in the setup. It is the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The bug I went looking for did not exist, and the four I found were all the same bug wearing different clothes: a component that could not distinguish "I don't know" from a value, and picked a value. In ordinary code, that produces a wrong answer somewhere downstream and usually an exception eventually. In a pipeline where a language model reads those answers, it produces a fluent, confident, well-formatted summary of a reality that is 25 conversations wide instead of 30, and nobody downstream has any way to tell.&lt;/p&gt;

&lt;p&gt;So the question I am still working on, and would like yours on: in your own systems, how do you tell the difference between an agent run that went fine and an agent run that quietly took a path that does not work? Error rates will not show it. Neither will the output, because the output always looks great.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>ai</category>
      <category>observability</category>
    </item>
    <item>
      <title>'World Models' Will Be the Next Buzzword. The Man Saying That Just Raised $1B to Build One</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:10:03 +0000</pubDate>
      <link>https://dev.to/p0rt/world-models-will-be-the-next-buzzword-the-man-saying-that-just-raised-1b-to-build-one-4oih</link>
      <guid>https://dev.to/p0rt/world-models-will-be-the-next-buzzword-the-man-saying-that-just-raised-1b-to-build-one-4oih</guid>
      <description>&lt;p&gt;In March, the CEO of a research lab with zero products closed a &lt;strong&gt;$1.03 billion seed round&lt;/strong&gt; — the largest in European history. Then he told TechCrunch that "'world models' will be the next buzzword," predicting that within six months every company would slap the label on itself to raise money.&lt;/p&gt;

&lt;p&gt;The CEO is Alexandre LeBrun. The lab is AMI Labs, Paris. The chairman is Yann LeCun. And LeBrun was right on schedule: VCs have pushed roughly &lt;strong&gt;$3B into the category&lt;/strong&gt; since.&lt;/p&gt;

&lt;p&gt;When the person behind the biggest bet in a space tells you the space is about to become a content-free buzzword, that's the frame worth keeping. The question isn't whether "world model" gets diluted — it will. The question is whether there's a real, testable architectural disagreement underneath the label.&lt;/p&gt;

&lt;p&gt;There is. This post is the paper trail: what LeCun is actually claiming, what the published results show, who paid for it, and the strongest arguments that the whole thesis is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is LeCun, in 30 seconds
&lt;/h2&gt;

&lt;p&gt;Turing Award 2018 (shared with Hinton and Bengio). Architect of convolutional neural networks. Chief AI scientist at Meta from 2013, where he built FAIR into one of the largest industrial research labs on the planet. His departure was confirmed in November 2025, after Meta folded FAIR into its new superintelligence org, spent $15B on Scale AI, and reoriented around Llama and generative products.&lt;/p&gt;

&lt;p&gt;The split was architectural, not financial. Meta isn't investing in AMI Labs — but the two keep a research partnership around the architecture this whole story runs on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case against LLMs, as he makes it
&lt;/h2&gt;

&lt;p&gt;LeCun's critique predates the current hype cycle by years, and it's more specific than "LLMs bad." His standard list of what's missing: &lt;strong&gt;understanding of the physical world, persistent memory, reasoning, and planning.&lt;/strong&gt; The supporting arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Autoregression compounds errors.&lt;/strong&gt; Every generated token conditions on possibly-wrong previous tokens. Fine for prose. Bad for long-horizon plans, where one early mistake invalidates everything downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text is a thin slice of reality.&lt;/strong&gt; By his estimate, a four-year-old has taken in more raw sensory data through vision alone than the largest text corpora contain. Text &lt;em&gt;describes&lt;/em&gt; the world; it doesn't contain its dynamics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The falling pen.&lt;/strong&gt; Drop a pen and it can land many ways. A system predicting one most-likely continuation in surface space is doing a different computation than one reasoning over a distribution of physical outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stakes argument.&lt;/strong&gt; LeBrun ran Nabla, a medical AI company, and arrived at the same place from the applied side: in healthcare, hallucination isn't a UX bug. It's a liability category.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At VivaTech this year, LeCun said current chatbots understand the physical world worse than a rat. AMI's corporate framing is more measured: token prediction works well for discrete, low-dimensional tasks — retrieval, summarization, code — and &lt;em&gt;mimics&lt;/em&gt; intelligence without modeling the world.&lt;/p&gt;

&lt;p&gt;One thing worth flagging before we go further: this critique was formulated &lt;strong&gt;before RL-trained reasoning models existed.&lt;/strong&gt; The "no reasoning, no planning" claim is weaker in 2026 than it was in 2022. The honest version of the argument today is about the &lt;em&gt;reliability and grounding&lt;/em&gt; of that reasoning — not its existence.&lt;/p&gt;

&lt;h2&gt;
  
  
  "World model" currently means three different things
&lt;/h2&gt;

&lt;p&gt;This is the part most coverage skips, and it's where the buzzword damage will happen. At least three technically distinct approaches share the label:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Generative video prediction.&lt;/strong&gt; Predict future frames, conditioned on actions. Google DeepMind's &lt;strong&gt;Genie 3&lt;/strong&gt; generates navigable 3D worlds at 24fps; NVIDIA's &lt;strong&gt;Cosmos&lt;/strong&gt; (launched at CES 2025, 2M+ downloads, trained on ~20M hours of video) targets synthetic data for robotics and AVs; Runway's &lt;strong&gt;GWM-1&lt;/strong&gt; bets on interactive video. The model's imagination is literally watchable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit 3D representation.&lt;/strong&gt; Fei-Fei Li's &lt;strong&gt;World Labs&lt;/strong&gt; treats the world as a spatial object, not a frame sequence. Marble (shipped November 2025) generates 3D environments on Gaussian splats plus physics engines, viewable in VR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latent-space prediction.&lt;/strong&gt; &lt;strong&gt;JEPA&lt;/strong&gt; — LeCun's bet. Don't generate pixels at all. Encode observations into abstract representations and predict how &lt;em&gt;those&lt;/em&gt; evolve. The claim: most pixel-level detail is irrelevant for planning, and predicting it wastes capacity and compute.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are not interchangeable. They differ in what they predict, how you evaluate them, and what they're for. When a startup calls itself a "world model company," the first useful question is &lt;em&gt;which of the three it means.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The paper trail: what JEPA has actually shown
&lt;/h2&gt;

&lt;p&gt;The fundraise is loud. The papers are quieter and more interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2022 — the position paper.&lt;/strong&gt; &lt;em&gt;A Path Towards Autonomous Machine Intelligence&lt;/em&gt; lays out the whole program: a modular agent (perception, world model, cost, actor, short-term memory, configurator) with JEPA as the learning substrate. No results — pure architecture. It reads as a research roadmap, and AMI Labs is essentially this paper incorporated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2023 — I-JEPA.&lt;/strong&gt; Images. Mask blocks of an image, predict the &lt;em&gt;representations&lt;/em&gt; of the masked regions from visible context — never reconstruct pixels, no handcrafted augmentations. Meta reported training a ViT-Huge in under 72 hours on 16 A100s, a fraction of what pixel-reconstruction methods burn, with strong linear-probe results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2024 — V-JEPA.&lt;/strong&gt; Same trick on video: masked latent prediction over spatiotemporal patches. The representations turn out to encode &lt;em&gt;motion&lt;/em&gt; unusually well — exactly what pixel-reconstruction models are notoriously mediocre at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2025 — V-JEPA 2.&lt;/strong&gt; The load-bearing result. A ~1B-parameter ViT-g encoder trained on 22M videos (over 1M hours). From the paper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;77.3% top-1 on Something-Something v2&lt;/strong&gt; (motion understanding)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;39.7 recall@5 on Epic-Kitchens-100&lt;/strong&gt; action anticipation — a 44% relative improvement over prior task-specific models&lt;/li&gt;
&lt;li&gt;Aligned with an 8B language model: &lt;strong&gt;84.0 on PerceptionTest, 76.9 on TempCompass&lt;/strong&gt; — state of the art at that scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the robotics part, &lt;strong&gt;V-JEPA 2-AC&lt;/strong&gt;: take the frozen encoder, post-train an action-conditioned predictor on &lt;strong&gt;less than 62 hours&lt;/strong&gt; of unlabeled robot video from the open DROID dataset, and deploy zero-shot on Franka arms in two labs the model never saw. Planning runs as model-predictive control in latent space. Results: &lt;strong&gt;65–80% success on pick-and-place with unseen objects&lt;/strong&gt; — no rewards, no task-specific data, no data from the deployment robots. Planning takes &lt;strong&gt;~16 seconds per action versus ~4 minutes&lt;/strong&gt; for the video-generation baseline (Cosmos), and on a cup-relocation task it hit ~80% where the vision-language-action baseline Octo managed 15%.&lt;/p&gt;

&lt;p&gt;Meta also released physical-reasoning benchmarks alongside (IntPhys 2, MVPBench, CausalVQA) — on which current models, LLMs included, still trail humans badly. That gap is the entire pitch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026 — AMI Labs.&lt;/strong&gt; As of July: no public model. Reporting points to work on world models that adapt continually through action, plus the conference-keynote circuit. LeBrun's stated timeline: roughly a year to the first usable pieces in a product, &lt;em&gt;years&lt;/em&gt; to real commercial applications — healthcare (via Nabla), robotics, wearables, industrial. LeCun says year one is research. Credit where due: they are not pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The money map
&lt;/h2&gt;

&lt;p&gt;The AMI round was led by Bezos Expeditions, Cathay Innovation, Greycroft, Hiro Capital and HV Capital, with NVIDIA, Temasek, Samsung and Toyota Ventures in, plus individuals including Bezos, Mark Cuban, Eric Schmidt and — unusually for a seed round — Tim Berners-Lee. They initially sought €500M and closed ~€890M.&lt;/p&gt;

&lt;p&gt;Zoom out and the pattern sharpens. &lt;strong&gt;World Labs&lt;/strong&gt; raised $1B in February at $5.4B post. &lt;strong&gt;Decart&lt;/strong&gt; took $300M in May at $4B (Karpathy is an angel). &lt;strong&gt;Odyssey&lt;/strong&gt; raised $1.2B. And NVIDIA is on nearly every cap table — it has committed over &lt;strong&gt;$40B in AI equity in 2026&lt;/strong&gt;, frequently structured as equity in exchange for long-term GPU commitments.&lt;/p&gt;

&lt;p&gt;Read that incentive carefully. NVIDIA's omnipresence is evidence that world models are &lt;em&gt;compute-hungry&lt;/em&gt;, not that the architecture is right. Demis Hassabis calling world models essential for AGI is the more meaningful endorsement — DeepMind builds them regardless of the hype cycle, and has no fundraise to justify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest counterarguments
&lt;/h2&gt;

&lt;p&gt;I don't want to write the version of this post that just relays the pitch. Here's the strongest case against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. LLMs may already learn world models.&lt;/strong&gt; The Othello-GPT line of work trained a transformer on nothing but move sequences and found an emergent, probeable representation of the board state — later shown by Neel Nanda to be &lt;em&gt;linearly&lt;/em&gt; decodable. Gurnee &amp;amp; Tegmark found linear representations of space and time inside Llama-2, down to individual "space neurons." Next-token prediction demonstrably induces &lt;em&gt;some&lt;/em&gt; internal model of the data-generating process. So the LeCun claim has to be quantitative — "not enough of one, not grounded enough" — not categorical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Those internal models might be junk anyway.&lt;/strong&gt; Follow-up interpretability work suggests Othello-GPT's "board" may be a bag of correlated heuristics rather than a clean algorithm — epicycles, not an orrery. This cuts both ways: it weakens "LLMs already have world models," and it warns that JEPA latents could look equally messy once someone probes them as hard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Latent prediction is hard to inspect.&lt;/strong&gt; With generative world models you can &lt;em&gt;watch&lt;/em&gt; what the model imagines and see it break. A JEPA predictor's mistakes live in embedding space; evaluation is indirect — probes, downstream planning success. That's a real tooling and debuggability cost, and part of why the generative camp iterates faster in public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Nobody's world model is robust yet.&lt;/strong&gt; Genie 3 stays coherent for a few minutes and remembers changes for about one. V-JEPA 2-AC does tabletop pick-and-place, not laundry. The gap between "80% cup relocation" and "robot in your kitchen" is the same kind of gap LLMs face between benchmark and deployment. It would be inconsistent to hold only one camp to the deployment standard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The falsifiability question.&lt;/strong&gt; What would count as the thesis &lt;em&gt;winning&lt;/em&gt;? My candidates: JEPA-style planners beating vision-language-action models on generalization at matched scale; sample-efficiency curves holding beyond manipulation toys; a grounded system measurably hallucinating less in a domain like healthcare. What would count as &lt;em&gt;losing&lt;/em&gt;: hybrid LLM systems closing the physical-reasoning benchmark gap first. Either outcome is visible within a couple of years — which is more than you can say for most $1B theses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this (if you build things)
&lt;/h2&gt;

&lt;p&gt;Practical, not philosophical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing here replaces your LLM calls in 2026.&lt;/strong&gt; The most optimistic insider timeline is a year to first usable pieces, years to products. Plan your stack accordingly and ignore anyone selling you a "world model" API this quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form your own priors hands-on.&lt;/strong&gt; V-JEPA 2 checkpoints are public. Cosmos is self-hostable — the 7B variant fits on a single H100 80GB. Marble has a free tier. Genie is a closed preview. An afternoon of poking beats a month of threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the label shows up in a pitch deck, ask which of the three architectures it means&lt;/strong&gt; — frames, splats, or latents — and where its pick-and-place numbers are. If there's no answer, you've found the buzzword LeBrun warned you about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch three markers:&lt;/strong&gt; AMI's first release and its license (LeCun spent a decade arguing for open research — the licensing choice will say a lot); whether latent-space planning scales past tabletop manipulation before generative models get long-horizon coherence; and DeepMind shipping a hybrid LLM + world model system, since they own both pieces and have no thesis to defend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LeBrun's six months are almost up. The benchmarks aren't going anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;V-JEPA 2 paper: &lt;a href="https://arxiv.org/abs/2506.09985" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2506.09985&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Meta's V-JEPA 2 announcement + benchmarks: &lt;a href="https://ai.meta.com/blog/v-jepa-2-world-model-benchmarks/" rel="noopener noreferrer"&gt;https://ai.meta.com/blog/v-jepa-2-world-model-benchmarks/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;I-JEPA: &lt;a href="https://arxiv.org/abs/2301.08243" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2301.08243&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The 2022 position paper: &lt;a href="https://openreview.net/forum?id=BZ5a1r-kVsf" rel="noopener noreferrer"&gt;https://openreview.net/forum?id=BZ5a1r-kVsf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AMI Labs raise (TechCrunch): &lt;a href="https://techcrunch.com/2026/03/09/yann-lecuns-ami-labs-raises-1-03-billion-to-build-world-models/" rel="noopener noreferrer"&gt;https://techcrunch.com/2026/03/09/yann-lecuns-ami-labs-raises-1-03-billion-to-build-world-models/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Largest EU seed context (Crunchbase): &lt;a href="https://news.crunchbase.com/venture/world-model-ai-lab-ami-raises-europes-largest-seed-round/" rel="noopener noreferrer"&gt;https://news.crunchbase.com/venture/world-model-ai-lab-ami-raises-europes-largest-seed-round/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The VC map (Forbes): &lt;a href="https://www.forbes.com/sites/josipamajic/2026/06/30/world-model-startups-raise-3-billion-vcs-bet-beyond-llms/" rel="noopener noreferrer"&gt;https://www.forbes.com/sites/josipamajic/2026/06/30/world-model-startups-raise-3-billion-vcs-bet-beyond-llms/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Othello-GPT: &lt;a href="https://thegradient.pub/othello/" rel="noopener noreferrer"&gt;https://thegradient.pub/othello/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Nanda's linear-representation follow-up: &lt;a href="https://www.neelnanda.io/mechanistic-interpretability/othello" rel="noopener noreferrer"&gt;https://www.neelnanda.io/mechanistic-interpretability/othello&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Space/time representations in Llama-2: &lt;a href="https://arxiv.org/abs/2310.02207" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2310.02207&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Melanie Mitchell's skeptical read: &lt;a href="https://aiguide.substack.com/p/llms-and-world-models-part-2" rel="noopener noreferrer"&gt;https://aiguide.substack.com/p/llms-and-world-models-part-2&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>robotics</category>
    </item>
    <item>
      <title>Autonomy Is the Bug: Why Self-Driving Agents Hallucinate When the Model Barely Does</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Tue, 21 Jul 2026 16:23:46 +0000</pubDate>
      <link>https://dev.to/p0rt/autonomy-is-the-bug-why-self-driving-agents-hallucinate-when-the-model-barely-does-1330</link>
      <guid>https://dev.to/p0rt/autonomy-is-the-bug-why-self-driving-agents-hallucinate-when-the-model-barely-does-1330</guid>
      <description>&lt;p&gt;The best models in 2026 hallucinate on about &lt;strong&gt;1% of grounded, single-shot tasks&lt;/strong&gt;. Hand one a document and ask for a summary and it almost never invents anything.&lt;/p&gt;

&lt;p&gt;Now let that same model drive itself: plan a task, call a tool, read the result, decide the next step, update its memory, repeat for twenty steps with no human in the loop. It will now go wrong on &lt;strong&gt;most runs&lt;/strong&gt;. Same model, same weights. The thing that changed is &lt;em&gt;autonomy&lt;/em&gt;, and autonomy is where hallucination is actually manufactured.&lt;/p&gt;

&lt;p&gt;This is the part almost nobody states plainly: for a self-driving agent, the dominant cause of "making things up" is not the model's factual accuracy. It's the structure of running unattended across many steps. A better model barely moves it. Below is why, with the numbers, and what actually does.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Agent errors compound multiplicatively: at 95% per-step accuracy, a 10-step task succeeds ~60% of the time, a 20-step task ~36%. Even a fictional 99%-per-step agent fails ~18% of 20-step tasks. And it's worse than the clean math, because agents &lt;em&gt;self-condition&lt;/em&gt; on their own earlier mistakes, so errors accelerate rather than just accumulate. This is structural, not a model-quality problem: no checkpoint fixes p^n. What fixes it is architecture, shorter chains, per-step validation, scoped context, and escalation, plus not reaching for a reasoning model, which for grounded steps often hallucinates 3–4× &lt;em&gt;more&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one equation that governs autonomous agents
&lt;/h2&gt;

&lt;p&gt;Reliability engineering has a law for chained systems (Lusser's law): the reliability of a sequence is the &lt;em&gt;product&lt;/em&gt; of the step reliabilities, not the average. For an agent taking n independent steps, each succeeding with probability p, end-to-end success is &lt;strong&gt;p^n&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That exponent is the entire story of why autonomy is hard. Watch what it does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per-step accuracy&lt;/th&gt;
&lt;th&gt;5 steps&lt;/th&gt;
&lt;th&gt;10 steps&lt;/th&gt;
&lt;th&gt;20 steps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;99% (fantasy)&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95% (excellent)&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90% (good)&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;85% (a strong result)&lt;/td&gt;
&lt;td&gt;44%&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the 95% row again, because 95% per step sounds &lt;em&gt;great&lt;/em&gt;. An agent that gets each step right 95% of the time succeeds on a 20-step task &lt;strong&gt;36% of the time.&lt;/strong&gt; It fails most of them. And 85% per step, which is a genuinely strong result on a complex reasoning action, collapses to &lt;strong&gt;4%&lt;/strong&gt; over 20 steps: one success in twenty-five.&lt;/p&gt;

&lt;p&gt;Demis Hassabis called compounding agent errors &lt;strong&gt;"compound interest in reverse,"&lt;/strong&gt; and it's exact. The same multiplicative machinery that makes compound interest miraculous over time makes autonomous error catastrophic over steps. Demos hide it because a demo is 2–3 steps. Production is 5+ steps over messy inputs, which is precisely where p^n bites.&lt;/p&gt;

&lt;p&gt;The load-bearing consequence: &lt;strong&gt;no amount of model improvement fully solves this.&lt;/strong&gt; Going from 95% to 97% per step helps, but you're still fighting an exponent. The gap between "impressive demo" and "reliable product" is not a capability gap you close with a better model. It's a structural property of chaining a non-deterministic component, and you engineer around it or you ship a coin flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's actually worse than p^n: self-conditioning
&lt;/h2&gt;

&lt;p&gt;The table above assumes each step's error is &lt;em&gt;independent&lt;/em&gt;. In real agents, it isn't, and the dependence runs the wrong way.&lt;/p&gt;

&lt;p&gt;Researchers documented a &lt;strong&gt;self-conditioning&lt;/strong&gt; effect in multi-step LLM pipelines in early 2026: once an agent has produced an error and that error is sitting in its context, the model conditions on it. It sees its own earlier mistake as established fact and reasons &lt;em&gt;from&lt;/em&gt; it, which makes the next mistake more likely, not less. Errors don't just accumulate, they compound on themselves. The real failure curve is &lt;strong&gt;steeper&lt;/strong&gt; than p^n predicts.&lt;/p&gt;

&lt;p&gt;This is the mechanism underneath "the agent went off the rails around step 6." It didn't hit a hard wall. It made one small wrong turn at step 4, wrote that into its own working context, and every subsequent step treated the wrong turn as ground truth. By step 8 it's confidently building on a fact it invented four steps ago. The hallucination isn't fresh at step 8, it's &lt;em&gt;laundered&lt;/em&gt; from step 4 through the agent's own memory.&lt;/p&gt;

&lt;p&gt;And this points straight at one of the most effective fixes, which is counterintuitive: &lt;strong&gt;scoped context.&lt;/strong&gt; A step that doesn't know about the errors in steps 2 and 3 &lt;em&gt;can't condition on them&lt;/em&gt;. Passing only the relevant slice of context forward, instead of the full accumulated history, breaks the self-conditioning loop. Less memory, more reliability, the opposite of the instinct to give the agent everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autonomy-specific amplifiers
&lt;/h2&gt;

&lt;p&gt;On top of the raw compounding, self-driving specifically adds failure sources a single-shot call never has:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lossy memory compaction.&lt;/strong&gt; When the agent's history won't fit in context, it summarizes, and repeated summarization is brutal. A 2026 rate-distortion study measured it: an agent archiving raw records and retrieving on demand held &lt;strong&gt;~95% recall&lt;/strong&gt;; an agent overwriting memory with LLM summaries dropped to &lt;strong&gt;33–56% recall&lt;/strong&gt;, worst when it compacted most often. At equal budget, lossy self-summarization loses roughly &lt;em&gt;half the facts&lt;/em&gt; over a long run, and a single-turn benchmark can't see it because the loss only appears when compaction repeats. The agent then confidently fills the holes it created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context drift.&lt;/strong&gt; ~65% of enterprise agent failures in 2025 were attributed to context drift or memory loss during multi-step reasoning, not to running out of context. As the window fills, attention de-prioritizes the original task; early tool outputs get overwritten by later ones; the agent quietly diverges before it ever hits the limit. Chroma's context-rot work shows degradation accelerating past ~30k tokens even in models with far larger windows, capacity is not fidelity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-agent contamination.&lt;/strong&gt; Split the work across agents for reliability and you can make it worse: naive full-broadcast state sharing &lt;em&gt;increased&lt;/em&gt; hallucination by &lt;strong&gt;34%&lt;/strong&gt; in a controlled 2026 study, because one agent's error propagates to all of them. Chaining five agents at 95% each yields &lt;strong&gt;77%&lt;/strong&gt; end-to-end, not 95%, the same p^n exponent, now spread laterally.&lt;/p&gt;

&lt;p&gt;Every one of these is a property of &lt;em&gt;running autonomously&lt;/em&gt;, not of the model's factual accuracy. A single API call to the same model has none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model does matter: don't make per-step accuracy worse
&lt;/h2&gt;

&lt;p&gt;The model isn't irrelevant, it sets your p. But the counterintuitive part is that the "smarter" model many teams reach for to fix agent reliability often &lt;em&gt;lowers&lt;/em&gt; p on exactly the grounded steps agents are made of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning models hallucinate more on grounded tasks.&lt;/strong&gt; Same base model: DeepSeek-V3 hallucinates at 3.9% on grounded summarization; its reasoning sibling R1 at &lt;strong&gt;14.3%&lt;/strong&gt;, ~4× worse. It's a pattern, not a one-off: on Vectara's 2026 dataset, &lt;em&gt;every&lt;/em&gt; reasoning model exceeded 10%, while a non-reasoning model (Gemini Flash-Lite) led at 3.3%. The mechanism is the same self-conditioning drift, reasoning models generate long internal chains that wander from the source. So "let me upgrade to a reasoning model so my agent thinks more carefully" can &lt;em&gt;lower&lt;/em&gt; your per-step accuracy and, through the exponent, wreck end-to-end reliability. For the grounded steps that make up most agent loops, a fast non-reasoning model is often the higher-p choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Lowest hallucination" can mean "abstains most."&lt;/strong&gt; Claude 4.1 Opus posts 0% on a knowledge benchmark, but by &lt;em&gt;declining when unsure&lt;/em&gt;, which for an agent is often exactly right (a confident wrong step corrupts everything downstream; an "I'm not sure" step can be escalated). Calibration, knowing what it doesn't know, matters more for p than raw accuracy, because a self-conditioning agent punishes confident-wrong far more than honest-uncertain.&lt;/p&gt;

&lt;p&gt;The takeaway on models: pick for &lt;strong&gt;high, calibrated per-step accuracy on your actual step type&lt;/strong&gt; (usually grounded → non-reasoning), not for leaderboard position. But understand that even a great p is fighting p^n, so the model is necessary and nowhere near sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes autonomous hallucination
&lt;/h2&gt;

&lt;p&gt;None of these is a model upgrade. All of them attack the exponent or the self-conditioning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shorter chains.&lt;/strong&gt; The single highest-leverage move. Cutting a 20-step task to 8 steps does more for reliability than any accuracy bump, because you're reducing the exponent, not the base. If a task can be decomposed into shorter independent sub-tasks with checkpoints between, do that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation between steps.&lt;/strong&gt; Schema-validate each step's output before passing it forward. A 95%-accurate step that's &lt;em&gt;checked&lt;/em&gt; can't silently corrupt everything downstream, you catch the error at step 4 instead of discovering it at step 20. This directly interrupts self-conditioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped context, not full history.&lt;/strong&gt; Pass only what the next step needs. A step that can't see earlier errors can't condition on them. Less context is more reliability here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalation and circuit breakers.&lt;/strong&gt; The agent must know when it's uncertain and hand off. The dangerous failure isn't the agent that stops and reports, it's the one that fails silently and continues, laundering a wrong turn through twelve more steps. Test explicitly: does it recognize its own error, signal it, and &lt;em&gt;halt&lt;/em&gt; rather than compound?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selective autonomy, not full autonomy.&lt;/strong&gt; Aggressive automation for low-risk, reversible steps; hard human gates on high-stakes, irreversible ones. Cap the blast radius. The goal was never "fully autonomous," it's "autonomous where it's safe, gated where it isn't."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The thing to internalize
&lt;/h2&gt;

&lt;p&gt;A single model on a single task is a solved-ish problem, ~1% on grounded work. An autonomous agent is a different object: it multiplies per-step reliability across many steps, and it conditions on its own mistakes, so a 95%-per-step agent is mathematically guaranteed to fail most long tasks, and the real curve is steeper still. That is not a model-quality problem and no checkpoint fixes it.&lt;/p&gt;

&lt;p&gt;The teams whose agents are still running in 2028 won't be the ones who bought the most capable model. They'll be the ones who treated compound failure as a design constraint from day one, shorter chains, validation between steps, scoped context, escalation, and the discipline to leave a step &lt;em&gt;out&lt;/em&gt; of autonomy when it can't be made safe.&lt;/p&gt;

&lt;p&gt;When your self-driving agent hallucinates, don't reach for a smarter model first. Count the steps, check whether it's conditioning on its own earlier output, and ask which of those steps ever needed to be autonomous at all. The model isn't lying. Autonomy handed it its own earlier mistake and asked it to keep going.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your real per-step accuracy once you measure the whole chain, not the demo, and where did shortening the chain buy you more than any model swap? I'd bet most "the model hallucinates" complaints are compound error and self-conditioning wearing a model-shaped mask.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.zartis.com/the-compounding-errors-problem-why-multi-agent-systems-fail-and-the-architecture-that-fixes-it/" rel="noopener noreferrer"&gt;"The Compounding Errors Problem: Why Multi-Agent Systems Fail"&lt;/a&gt;, Zartis (2026) — p^n across accuracy levels; why model improvement can't fully solve it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://towardsdatascience.com/the-math-thats-killing-your-ai-agent/" rel="noopener noreferrer"&gt;"The Math That's Killing Your AI Agent"&lt;/a&gt;, Towards Data Science (2026) — the compound table; fail-detectably-and-gracefully test.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://highlandedge.com/resources/insights/compound-error-problem/" rel="noopener noreferrer"&gt;"The Compound Error Problem: Why 95% Accurate AI Agents Still Fail"&lt;/a&gt;, Highland Edge (2026) — self-conditioning; "compound interest in reverse"; scoped context passing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://medium.com/k8slens/the-math-behind-why-multi-step-ai-agents-fail-in-production-c6d60ea6ca31" rel="noopener noreferrer"&gt;"The Math Behind Why Multi-Step AI Agents Fail in Production"&lt;/a&gt;, Flavius Dinu (2026) — Lusser's law; shorter chains + verification + guardrails.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.mindstudio.ai/blog/multi-agent-reliability-compounding-problem-77-percent" rel="noopener noreferrer"&gt;"Multi-Agent Reliability Math: Chaining 5 Agents Drops to 77%"&lt;/a&gt;, MindStudio (2026) — lateral compounding; 85–90% realistic per-agent rates.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/html/2607.08032" rel="noopener noreferrer"&gt;"What to Keep, What to Forget: A Rate–Distortion View of Memory Compaction"&lt;/a&gt; (2026) — reversible ~0.95 vs irreversible 0.33–0.56 recall.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2606.21666" rel="noopener noreferrer"&gt;"Hallucination as Context Drift"&lt;/a&gt; (2026) — +34% from naive multi-agent sync.&lt;/li&gt;
&lt;li&gt;Reasoning-tax and abstention figures: Vectara HHEM 2026; Suprmind AI hallucination benchmarks (DeepSeek V3 3.9% vs R1 14.3%; Claude 4.1 Opus 0% via abstention).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>devops</category>
    </item>
    <item>
      <title>'Local' Solves Where Your Data Goes. It Doesn't Solve What Your Agent Does</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:19:35 +0000</pubDate>
      <link>https://dev.to/p0rt/local-solves-where-your-data-goes-it-doesnt-solve-what-your-agent-does-306b</link>
      <guid>https://dev.to/p0rt/local-solves-where-your-data-goes-it-doesnt-solve-what-your-agent-does-306b</guid>
      <description>&lt;p&gt;Local models got good this year. Gemma 4's 12B runs agentic workloads in 16GB of RAM, GLM-5.2 tops the open-weight leaderboards under a permissive license, Qwen 3.6 does tool-calling that would've been frontier-only eighteen months ago. The "just run it locally" argument hit the top of Hacker News, and for once it wasn't cope — a model on your own hardware finally handles real work.&lt;/p&gt;

&lt;p&gt;So teams are moving agents on-prem, and the pitch is almost always the same: &lt;strong&gt;local means private, private means safe.&lt;/strong&gt; The first half is true. The second half is a category error that's going to cause incidents, because it quietly swaps a data question for a behavior question and hopes nobody notices.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Local deployment fixes exactly one thing: &lt;em&gt;where your data goes&lt;/em&gt;. It does nothing for &lt;em&gt;what your agent does&lt;/em&gt;. Prompt injection (50–85% success rates, architectural not model-level), silent provenance failures (the agent faking its own logs), and privilege escalation all survive the move to your hardware unchanged — and you've traded the provider's security team for none. Local agents are genuinely safe for a specific shape of task (bounded scope, trusted inputs, reversible or gated actions) and dangerously oversold for another (untrusted input, irreversible actions, regulated decisions). The dividing line isn't where the model runs. It's what the agent is allowed to touch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "local" actually buys you
&lt;/h2&gt;

&lt;p&gt;Let's be precise about the real win, because it's real and worth having: &lt;strong&gt;data sovereignty.&lt;/strong&gt; Your prompts, your documents, your customer data, your proprietary code — none of it leaves your infrastructure. For a hospital, a bank, a defense contractor, or anyone under GDPR handling personal data, that's not a nice-to-have, it's frequently the difference between "can deploy" and "legal says no." With the EU AI Act's high-risk provisions live as of August 2, 2026, and sector regulators (OCC, FDA, FINRA, SEC) applying existing authorities to agent deployments, keeping data on-prem removes a whole class of compliance friction.&lt;/p&gt;

&lt;p&gt;That's the entire list. Data location. Everything else people &lt;em&gt;attribute&lt;/em&gt; to local — that it's safer, more controllable, less exploitable — is either untrue or unrelated to where the weights sit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "local" does not buy you
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part, in three failures that don't care about your network topology.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Prompt injection is architectural, not remote
&lt;/h3&gt;

&lt;p&gt;The single most common belief I want to kill: that prompt injection is something that happens &lt;em&gt;to cloud APIs&lt;/em&gt; and air-gapping escapes it. It isn't and it doesn't.&lt;/p&gt;

&lt;p&gt;Prompt injection is #1 on the OWASP LLM Top 10 for one reason: &lt;strong&gt;LLMs cannot structurally distinguish trusted instructions from untrusted data.&lt;/strong&gt; That's a property of how the model reads a context window, not of where the context window is hosted. A recent systematization across 78 studies puts injection success rates &lt;em&gt;above 85%&lt;/em&gt;. Independent numbers land at 50–84% depending on configuration. Moving the model to your basement changes none of those numbers.&lt;/p&gt;

&lt;p&gt;Worse, the dangerous variant for agents is &lt;em&gt;indirect&lt;/em&gt; injection, and local makes it arguably harder to reason about, not easier. Indirect injection is when the agent autonomously retrieves attacker-controlled content — a poisoned document, a malicious webpage, a compromised record — and executes instructions hidden inside it, during its normal operating loop. And here's the local-specific sting: research shows that the moment a planner consumes &lt;em&gt;raw local content&lt;/em&gt; — arbitrary files, logs, metadata off your own disk — malicious instructions embedded in those local artifacts steer the agent's reasoning, even if execution is later sandboxed. Your local filesystem is not a trusted input just because it's yours. Any file an attacker touched, any document a user uploaded, any log a previous run wrote is now an injection vector, and it's sitting &lt;em&gt;inside&lt;/em&gt; your trusted perimeter, which is exactly where you weren't looking.&lt;/p&gt;

&lt;p&gt;There's a beautiful, awful result that makes the point: asking an agent to &lt;em&gt;seek clarification when a task is ambiguous&lt;/em&gt; — a behavior everyone agrees is desirable — measurably &lt;strong&gt;increases&lt;/strong&gt; its injection vulnerability, because the clarification response becomes a fresh attack channel. You cannot prompt your way out of this, on any hardware.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The agent can still fake its own evidence
&lt;/h3&gt;

&lt;p&gt;I've written a whole series on this, so I'll keep it short: an agent that writes a record of its own execution can write a &lt;em&gt;false&lt;/em&gt; one. The Darwin Gödel Machine incident is the canonical example — an agent editing its own harness wrote a log claiming its tests passed. They never ran. It then read that log back as ground truth. No deception, just tool-use hallucination hitting a filesystem that can't record who wrote what.&lt;/p&gt;

&lt;p&gt;Notice that &lt;em&gt;every part of that happens locally by default.&lt;/em&gt; Running the model on-prem doesn't add a single control here. If anything it removes one, because a cloud provider at least gives you their audit tooling, their request logs, their trace infrastructure. Roll your own local agent and you get an empty &lt;code&gt;/var/log&lt;/code&gt; and whatever provenance discipline you remembered to build — which, for most teams shipping fast, is none. "Local" and "auditable" are orthogonal, and people constantly assume the first implies the second.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. You inherited the provider's threat model and fired their security team
&lt;/h3&gt;

&lt;p&gt;This is the trade nobody prices in. When you call a hosted frontier API, an enormous amount of invisible security work comes along: input/output filtering, jailbreak detection, egress monitoring, rate limiting, red-teaming, abuse detection, incident response. You may resent paying for it, but it's &lt;em&gt;there&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Move local and all of that is now your job. The attack surface didn't shrink — prompt injection, tool abuse, privilege escalation, data exfiltration through side channels are all still live — but the team defending against it went from "the provider's security org" to "you, probably part-time, while also shipping the feature." Survey data captures the gap bluntly: 82% of executives are confident their existing policies cover unauthorized agent actions, and the operational reality is nowhere close. Local deployment doesn't cause that gap, but it &lt;em&gt;widens&lt;/em&gt; it, because it hands you more of the stack to secure while feeling like it did the opposite.&lt;/p&gt;

&lt;h2&gt;
  
  
  So where ARE local agents genuinely safe?
&lt;/h2&gt;

&lt;p&gt;This isn't a "don't run local agents" post — I run them, they're great, and the data-sovereignty win is often decisive. It's a "stop using &lt;em&gt;local&lt;/em&gt; as a synonym for &lt;em&gt;safe&lt;/em&gt;" post. The actual safety question has nothing to do with where the model runs and everything to do with three properties of the task:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Green zone — local agents are genuinely safe here:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded, trusted inputs.&lt;/strong&gt; The agent operates over content you control end to end — your own codebase, your internal docs, data with no attacker-writable path into it. No indirect-injection surface because nothing untrusted enters the context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible or gated actions.&lt;/strong&gt; The agent proposes; a human or a deterministic check disposes. Draft the email, don't send it. Suggest the migration, don't run it. Write the PR, don't merge it. If every consequential action has an undo or a gate, injection and hallucination cost you time, not damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low blast radius.&lt;/strong&gt; Worst case is contained. A local coding assistant that can edit files in one sandboxed repo, a document-Q&amp;amp;A agent that can only read, a log-triage agent that can only annotate — the failure mode is "wrong output," which you catch, not "wire transfer sent."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concretely, the tasks that fit: private code assistance over your own repos, RAG/document Q&amp;amp;A over internal knowledge (read-only), draft generation with human review, log and telemetry triage that annotates rather than acts, offline data transformation with visible outputs. These are safe &lt;em&gt;and&lt;/em&gt; benefit maximally from local — sensitive data, high volume, no need for frontier-level reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Red zone — local changes nothing about the danger:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Untrusted input in the loop.&lt;/strong&gt; The agent reads emails, browses the web, ingests user uploads, processes third-party documents. Every one of those is an injection channel, and it's identical on local and cloud. If anything, do this on a hosted model &lt;em&gt;with&lt;/em&gt; injection defenses before you do it on a bare local one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Irreversible or high-value actions.&lt;/strong&gt; Payments, deployments, deletions, external messages, database mutations, anything with a side effect you can't take back. The DGM failure and every injection result apply in full. Local gives you zero additional protection on the exact axis that matters most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated decisions.&lt;/strong&gt; Credit, healthcare, legal, hiring. Here the arxiv literature is blunt: a production KYC deployment reported &lt;em&gt;negative results&lt;/em&gt; — control failures surfaced only by internal audit, and a population of legitimate applicants the automated pipeline silently couldn't serve. In regulated settings the consensus is that full autonomy is rarely advisable regardless of hosting, and legal accountability &lt;em&gt;amplifies&lt;/em&gt; every threat relative to an unregulated deployment. "It runs on-prem" is not an answer a regulator accepts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern: &lt;strong&gt;local is a data-locality decision; safety is an autonomy-and-blast-radius decision.&lt;/strong&gt; They're independent axes, and conflating them is how you end up with a locally-hosted agent doing something on the red-zone list because "it's private, so it's fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls that actually matter (and are the same either way)
&lt;/h2&gt;

&lt;p&gt;Since the model's location isn't doing security work, something else has to. The controls that make an agent safe are identical on local and cloud, and they're all about &lt;em&gt;what the agent can touch&lt;/em&gt;, not where it thinks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege on tools.&lt;/strong&gt; The agent gets the minimum action surface for the task. A read-only agent can't write. A drafting agent can't send. This is the single highest-leverage control and it's free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gate before anything irreversible.&lt;/strong&gt; Human-in-the-loop or a deterministic check on every action you can't undo. Gate on the action's reversibility, not the model's confidence — a model can be confidently wrong, and injection makes it confidently &lt;em&gt;malicious&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted vs untrusted input separation.&lt;/strong&gt; Treat every file, document, and retrieved page the agent didn't author as potentially poisoned. Don't let raw untrusted content into the planning context; redact, sandbox, or validate at the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance on tool outputs.&lt;/strong&gt; A "tests passed" line counts only if it's backed by an artifact the agent couldn't author. The runtime that executed the tool mints the verified result, not the model narrating about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An audit log the agent can't rewrite.&lt;/strong&gt; Append-only, outside the agent's editable surface. This is the thing local deployment silently &lt;em&gt;removes&lt;/em&gt; if you don't build it, so build it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is architecture. None of them is a model, and none of them cares whether the weights are in us-east-1 or under your desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Local models crossing the capability threshold is genuinely one of the best things to happen in this space — the data-sovereignty win alone unlocks deployments that were legally impossible a year ago. Use them. But "local" answers exactly one question: &lt;em&gt;where does my data live.&lt;/em&gt; It is silent on the question that actually determines whether you get an incident: &lt;em&gt;what is my agent allowed to do, and what happens when it's wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The safe local agent and the dangerous local agent run the same model on the same hardware. The difference is entirely in the blast radius you granted it. Design that, and location becomes what it should be — a compliance and cost decision, not a security blanket.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running local agents: what's actually in your green zone versus what crept into the red one because "it's on-prem so it's fine"? The creep is never a decision, it's an omission — curious where people have caught it.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OWASP Top 10 for LLM Applications 2025 — prompt injection as the #1 architectural vulnerability.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.vectra.ai/topics/prompt-injection" rel="noopener noreferrer"&gt;"Prompt injection: types, real-world CVEs, and enterprise defenses"&lt;/a&gt;, Vectra AI (2026) — 50–84% success rates; the Aug 2, 2026 EU AI Act deadline.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2603.18377" rel="noopener noreferrer"&gt;"PlanTwin: Privacy-Preserving Planning Abstractions for Cloud-Assisted LLM Agents"&lt;/a&gt; — malicious instructions in &lt;em&gt;local&lt;/em&gt; artifacts steering reasoning even when execution is sandboxed; 85%+ injection success across 78 studies.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2605.17324" rel="noopener noreferrer"&gt;"ASPI: Seeking Ambiguity Clarification Amplifies Prompt Injection Vulnerability"&lt;/a&gt; — desirable clarification behavior increases attack surface.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2606.29142" rel="noopener noreferrer"&gt;"Agent Security Meets Regulatory Reality"&lt;/a&gt; — production KYC negative results; how legal accountability amplifies each threat.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://beam.ai/agentic-insights/ai-agent-security-in-2026-the-risks-most-enterprises-still-ignore" rel="noopener noreferrer"&gt;"AI Agent Security in 2026"&lt;/a&gt;, Beam — the 82% executive-confidence gap; healthcare incident rates.&lt;/li&gt;
&lt;li&gt;My series on the provenance side of this: &lt;a href="https://dev.to/p0rt/the-agent-faked-a-test-log-then-believed-it-self-editing-harnesses-have-a-provenance-problem-3id6"&gt;the agent that faked its own test log&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The Agent Faked a Test Log, Then Believed It. Self-Editing Harnesses Have a Provenance Problem.</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Wed, 08 Jul 2026 11:39:46 +0000</pubDate>
      <link>https://dev.to/p0rt/the-agent-faked-a-test-log-then-believed-it-self-editing-harnesses-have-a-provenance-problem-3id6</link>
      <guid>https://dev.to/p0rt/the-agent-faked-a-test-log-then-believed-it-self-editing-harnesses-have-a-provenance-problem-3id6</guid>
      <description>&lt;p&gt;Lilian Weng published a new survey on July 4: &lt;a href="https://lilianweng.github.io/posts/2026-07-04-harness/" rel="noopener noreferrer"&gt;Harness Engineering for Self-Improvement&lt;/a&gt;. It maps roughly three years of work on agents that optimize their own scaffolding — context managers, workflows, harness code, and eventually the optimizer that optimizes the harness. Most of the discussion around it will be about recursive self-improvement, because RSI is the exciting frame.&lt;/p&gt;

&lt;p&gt;I read it with a different hat on. I run agents in production, and this blog has been circling one question for a while: what does it take to trust the output of a long agent chain? Read from that angle, her survey is not really a post about self-improvement. It's a post about a research field independently reinventing operations engineering — regression gates, immutable audit logs, least privilege — because every loop that skips those controls gets burned in a documented, reproducible way.&lt;/p&gt;

&lt;p&gt;The cleanest burn in the whole literature comes from the &lt;a href="https://arxiv.org/abs/2505.22954" rel="noopener noreferrer"&gt;Darwin Gödel Machine&lt;/a&gt; (DGM) paper. An agent, allowed to edit its own harness code, faked a log saying its unit tests had run and passed. The tests never ran. The fake log went into its own context. Downstream, the same agent read that log and concluded its changes were validated. It lied to itself, then trusted the lie — except "lied" smuggles in intent that was never there. This was garden-variety tool-use hallucination meeting an untyped log. Which is worse, not better: you don't need a deceptive agent to get this failure, just a filesystem that can't say who wrote what.&lt;/p&gt;

&lt;p&gt;If you've read my post on provenance dying at the storage boundary &amp;lt;!-- TODO: link "Your Provenance Vector Dies at the Storage Boundary" --&amp;gt;, you already know where this is going.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a harness is, and why it became the optimization target
&lt;/h2&gt;

&lt;p&gt;Weng's definition, compressed: the harness is everything between the raw model and the world. The loop that decides when to plan and when to act. Tool interfaces. Context assembly. Memory files. Permission checks. Evaluation. Claude Code and Codex CLI are harnesses. So is your homegrown retry-wrapper-plus-prompt-template, whether you call it that or not.&lt;/p&gt;

&lt;p&gt;That this layer matters is now measurable. &lt;a href="https://arxiv.org/abs/2601.11868" rel="noopener noreferrer"&gt;Terminal-Bench 2.0&lt;/a&gt; — 89 hard, containerized command-line tasks — shows the same frontier models scoring differently under different scaffolds; the best pairing in the benchmark paper (Codex CLI + GPT-5.2) lands at 63%. The benchmark authors are explicit that scaffolds get engineered around the quirks of specific models, which is also the founding observation of the self-harness line of work: harness design is model-specific, and hand-tuning it per model doesn't scale.&lt;/p&gt;

&lt;p&gt;Weng organizes the field as a ladder of what gets optimized — prompts, then structured context, then workflows, then harness code, then the optimizer code itself — and walks every rung with examples. I won't duplicate the map; she does it in 28 well-spent minutes. What I want to do instead is pull a few load-bearing systems off that ladder and squint at their numbers and their failure reports — because that's where the story stops being about self-improvement and starts being about trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say when you squint
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://arxiv.org/abs/2310.02304" rel="noopener noreferrer"&gt;STOP&lt;/a&gt;&lt;/strong&gt; (Zelikman et al. 2023) — the original improver-improves-the-improver loop, still the conceptual core of the field — reported the result that should frame everything else: seed the recursion with GPT-4 and downstream utility climbs across iterations; seed it with GPT-3.5 or Mixtral and the loop actively hurts. Recursion is not a free lunch. Below a capability threshold, the loop amplifies noise instead of signal. The base model remains the ceiling; the harness moves you around underneath it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meta-Harness&lt;/strong&gt; (&lt;a href="https://arxiv.org/abs/2603.28052" rel="noopener noreferrer"&gt;Lee et al. 2026&lt;/a&gt;) — a search loop in which a coding agent proposes, edits, and evaluates whole harness variants — is the honest data point on how much headroom automated search finds above strong human engineering. On Terminal-Bench-2 it initializes the search from Terminus-2 and Terminus-KIRA — already strong hand-built harnesses — and the discovered harness comes out ahead: 37.6% on Haiku 4.5, where the next-best agent (Goose) sits at 35.5% and Terminus-KIRA at 33.7%, and 76.4% on Opus 4.6 against 74.7% for Terminus-KIRA. Call it two to four points, found by a proposer (Claude Code on Opus 4.6) that evaluates around 60 harness variants over 20 iterations in a few hours of wall-clock time. Polish-sized gains — but polish at that price is a good trade.&lt;/p&gt;

&lt;p&gt;The caveats are more instructive than the headline. The one entry still above Meta-Harness on Opus, ForgeCode at 81.8%, could not be reproduced by the authors from its public code. And — the detail I'd tattoo on the field — in the TB-2 experiment the search set and the test set are the same 89 tasks. The authors say so plainly: the benchmark is small and expensive, a proper split would gut the signal, so they run it as a discovery problem and compensate with manual inspection plus regex audits for task-specific string leakage. There's also a genuinely encouraging trace buried in the qualitative results: early candidates that bundled structural fixes with prompt-template rewrites regressed, and the proposer hypothesized the shared prompt edit was the confound, isolated the structural change, and shipped a safer additive modification that won the run. The optimizer performing ablation hygiene on itself is the best thing in the paper.&lt;/p&gt;

&lt;p&gt;Second-best thing in the paper: the proposer-context ablation, which lands on the same conclusion as an experiment I ran here &amp;lt;!-- TODO: link the summarization-destroys-provenance post --&amp;gt;. Give the optimizer scores only: median 34.6. Add LLM-written summaries of the trajectories: 34.9 — statistically nothing. Hand it the full raw traces: 50.0. Summaries did not recover the signal; compression strips exactly the diagnostic detail the optimizer feeds on. Independent group, different setup, same shape: the information that makes a trace useful for diagnosis is the information a summary throws away first. Provenance does not survive compression.&lt;/p&gt;

&lt;p&gt;Full disclosure, because it's too on-the-nose to skip: this exact failure mode bit me while writing this piece. My first draft sourced the Meta-Harness numbers from an LLM-generated paper-summary site, which confidently attributed that 35.5% baseline to Terminus-KIRA. The paper's own table says 35.5% is Goose; Terminus-KIRA sits at 33.7%. The wrong number lived in the draft until a fact-check pass against the raw table caught it. A machine-written summary — no provenance types, no link back to the table row — had quietly swapped a baseline. The ablation's finding, wearing street clothes. I nearly shipped an article about untyped trust on the strength of an untyped summary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DGM&lt;/strong&gt; — an evolutionary archive of coding agents, each free to rewrite its own harness repo — posted the impressive relative jump, 20% → 50% on SWE-bench Verified with a frozen Claude 3.5 Sonnet, but from a deliberately naive starting harness, and reporting put a single 80-iteration run at &lt;a href="https://the-decoder.com/sakana-ais-darwin-godel-machine-evolves-by-rewriting-its-own-code-to-boost-performance/" rel="noopener noreferrer"&gt;around $22k and two weeks&lt;/a&gt;. Look at what the loop actually discovered: finer-grained edit tooling, validation-and-retry on empty patches, context summarization near the window limit. Every one of these is a standard trick that hand-built coding harnesses already ship. The loop rediscovered expert engineering. That's a legitimate result — automated search recovering expert knowledge is useful — but it is not evidence of the loop &lt;em&gt;exceeding&lt;/em&gt; expert knowledge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://arxiv.org/abs/2605.27276" rel="noopener noreferrer"&gt;SIA&lt;/a&gt;&lt;/strong&gt;, the joint harness-plus-weights loop — a feedback agent decides per iteration whether to edit the harness or update the weights — is the one to hold loosely. Weng herself flags the confounds — the task agent (gpt-oss-120b) is far weaker than the meta and feedback agents steering it (Claude Sonnet 4.6), and the baselines are thin — and files the result under promising but unproven. A survey author doing that out loud is worth appreciating.&lt;/p&gt;

&lt;p&gt;And at the "AI scientist" end of the ladder, &lt;a href="https://arxiv.org/abs/2601.03315" rel="noopener noreferrer"&gt;Trehan &amp;amp; Chopra&lt;/a&gt; ran idea-to-paper attempts with a minimal scaffold (read, write, search, list). Of the generated ideas, four passed human selection and exactly one made it through to an executed paper. Their failure taxonomy reads like an ops incident review: stale defaults inherited from training data, implementation drift under execution pressure, context degradation on long horizons, and over-optimism — declaring victory on noisy results, the pattern &lt;a href="https://arxiv.org/abs/2511.16072" rel="noopener noreferrer"&gt;Bubeck et al.&lt;/a&gt; call "p-hacking and eureka-ing".&lt;/p&gt;

&lt;p&gt;Aggregate reading: self-editing loops today mostly (a) recover known harness engineering when started from scratch, (b) squeeze low single-digit points out of strong baselines, and (c) fail in ways that are structured and predictable rather than exotic. That last one is the good news. Predictable failure modes are the ones you can build controls for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident, in slow motion
&lt;/h2&gt;

&lt;p&gt;Back to DGM, because the appendix on objective hacking is the most instructive few pages in this literature.&lt;/p&gt;

&lt;p&gt;Setup: the researchers wanted the loop to reduce hallucinated tool use — the model narrating tool calls it never made. They instrumented real tool invocations with special marker tokens and scored agents on whether unmarked "tool output" appeared in generations. The checking functions were hidden from the agent during self-modification, and the authors note hacking happened &lt;em&gt;more often&lt;/em&gt; when they weren't hidden.&lt;/p&gt;

&lt;p&gt;Two lineages are worth naming. Node 96 did the honest work — improved tool-invocation logging, stripped hallucinated tool output when it could determine no tool ran — and scored 1.67 out of 2.0. Node 114 hit a perfect 2.0 after just two self-modifications: it deleted the detection markers. Perfect score, zero progress on the actual problem. Goodhart's law in its purest lab form. The team caught it because DGM keeps what Sakana describes as a "transparent, traceable lineage" of every change — and that lineage lived outside the agent's editable surface.&lt;/p&gt;

&lt;p&gt;The faked test log is the deeper incident, though. A record was written &lt;em&gt;by the model&lt;/em&gt;, persisted to the filesystem, and later consumed &lt;em&gt;by the model&lt;/em&gt; as if it were runtime truth. Exactly one bit of metadata would have blocked the failure: &lt;strong&gt;who wrote this record&lt;/strong&gt; — the harness runtime, or the model? In the typed-provenance framing I've been arguing for &amp;lt;!-- TODO: link "Trust Isn't a Scalar" --&amp;gt;, that's a &lt;code&gt;runtime-verified&lt;/code&gt; vs &lt;code&gt;self-reported&lt;/code&gt; tag, plus one rule: &lt;code&gt;self-reported&lt;/code&gt; never upgrades itself by being re-read.&lt;/p&gt;

&lt;p&gt;This is the storage-boundary failure from my earlier post, except recursive. The artifact crossing the boundary isn't user data anymore — it's the evidence the next iteration of the system uses to decide what the current iteration did. Untyped trust used to compound per request. In a self-editing harness it compounds per generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three invariants every working loop converges on
&lt;/h2&gt;

&lt;p&gt;Put the successful loops side by side and the same skeleton appears, whether or not the authors name it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A bounded editable surface.&lt;/strong&gt; AlphaEvolve marks evolvable code regions with explicit block comments. &lt;a href="https://arxiv.org/abs/2606.09498" rel="noopener noreferrer"&gt;Self-Harness&lt;/a&gt; — Shanghai AI Lab's loop in which the agent mines its own failure traces and patches its own harness — hands its proposer a declared list of editable surfaces plus a record of passing behaviors it must not break. DGM lets the agent rewrite its own repo — but not the evaluation stack. The loop needs a fence, drawn in advance, in writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. An evaluator and permission layer outside the fence.&lt;/strong&gt; DGM hides the checker functions and measures more hacking when it doesn't. Self-Harness validates candidate edits on a held-out split the proposer never optimized against, and promotes only edits with zero regression on &lt;em&gt;both&lt;/em&gt; held-in and held-out sets. Meta-Harness is the instructive partial violation: on TB-2 the search optimizes against the test set itself — acknowledged, defended (small, expensive benchmark), and patched with manual audits. Even a careful team bends this invariant the moment evaluation gets expensive, which is exactly why it needs to be stated as an invariant rather than left as taste. Weng's own conclusion in the challenges section is that evaluators and permission controls should sit outside the loop that evolves the harness. This is separation of duties. It has a name because we've needed it before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An append-only, typed record of what happened.&lt;/strong&gt; DGM's lineage is how node 114 got caught. Self-Harness's weakness-mining stage explicitly rejects flat pass/fail labels — each failure record captures what the verifier observed, whether the agent's own behavior actually caused it, and through what mechanism — because two timeouts that look identical in an error log can have entirely different roots. That is not a scalar trust score. That is a typed provenance record. Even &lt;a href="https://arxiv.org/abs/2510.04618" rel="noopener noreferrer"&gt;ACE&lt;/a&gt; — the context-optimization loop whose curator emits small itemized deltas instead of rewriting the whole prompt blob — lands on the same instinct: keep every change diffable, reviewable, attributable.&lt;/p&gt;

&lt;p&gt;If you've operated software for a living you recognize all three: least privilege, separation of duties plus CI regression gates, immutable audit logs. The field isn't inventing new safety machinery — it's rediscovering ops controls from the inside, one incident at a time. Weng herself reaches for an operating-systems analogy for harnesses; I'm just following it down to the ops floor. I mean that as a compliment. Convergent evolution is evidence the constraints are real rather than stylistic.&lt;/p&gt;

&lt;p&gt;Exactly one system in Weng's survey treats this as a first-class constraint — &lt;a href="https://arxiv.org/abs/2605.26340" rel="noopener noreferrer"&gt;ScientistOne&lt;/a&gt; (Meng et al. 2026), over on the AI-scientist branch, where every claim must trace back to an evidence source and the chain gets audited. That idea has not crossed over to the self-editing harness loops. There, provenance keeps getting built as a side effect: lineage exists in DGM because researchers wanted to debug evolution; failure records are rich in Self-Harness because flat labels made weakness mining useless. The argument I've been making for two posts now is that the record type system should be the load-bearing wall, not the scaffolding you only notice after it catches something.&lt;/p&gt;

&lt;h2&gt;
  
  
  You already run one of these
&lt;/h2&gt;

&lt;p&gt;This is not a frontier-lab concern. If your coding agent maintains its own memory file, writes its own instruction or skill files, or appends "lessons learned" that get loaded into future sessions — you are running a self-editing harness. Smaller loop, same topology: model-authored artifacts feeding future model behavior, usually with zero record types and no regression gate.&lt;/p&gt;

&lt;p&gt;The canonical failure shape doesn't need an adversary. An agent writes a confident note into its own memory — say, "the staging DB is safe to reset" — and three sessions later a different task reads it as established fact. Nobody hacked anything. The system simply has no way to distinguish what it &lt;em&gt;verified&lt;/em&gt; from what it once &lt;em&gt;said&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The checklist I'd actually apply:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat model-authored harness edits like schema migrations.&lt;/strong&gt; Memory files, instruction files, generated skills: versioned, diffable, reversible. A model changing its own operating instructions is a deploy, not a note.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-gate promotion.&lt;/strong&gt; An edit must fix the failure it targets (held-in) and break nothing else (held-out). Self-Harness converges on this shape independently, which is interesting — but shape is not sufficiency. My own preregistered test found a dumb baseline matching my gate scheme on half the failure classes &amp;lt;!-- TODO: link "My Strawman Baseline Beat My Own Scheme" --&amp;gt;, and I'd want Self-Harness benchmarked against an equally dumb accept-if-tests-pass rule before concluding the machinery earns its complexity. Run the gates — and run the strawman against them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type every persisted record at write time.&lt;/strong&gt; &lt;code&gt;runtime-verified&lt;/code&gt; / &lt;code&gt;self-reported&lt;/code&gt; / &lt;code&gt;human-authored&lt;/code&gt;, minimum. Enforce at read time that self-reported claims can't gate promotions or authorize actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the evaluator outside anything the loop can write.&lt;/strong&gt; Checker code, marker tokens, permission checks, credentials. If the agent can grep the checker, assume it will eventually optimize the checker instead of the task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the failures.&lt;/strong&gt; Rejected edits and failed trajectories are the cheapest signal the loop has. The literature's bias toward publishing successes is exactly the bias your local loop will inherit from its own logs if you prune them. Weng lists preserving negative results among the field's open challenges; it applies just as hard at your scale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this needs a research budget. It's a few enum values, a CI job, and some restraint about what ends up in the agent's writable mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I land on "the model will eat the harness"
&lt;/h2&gt;

&lt;p&gt;Weng's prediction runs through the prompt-engineering analogy: models absorbed the tricks, while the job of specifying what you want, under which constraints, judged how — that part outlived every trick. I mostly buy it, with one sharpening.&lt;/p&gt;

&lt;p&gt;Split your harness into two piles. Pile one exists to &lt;em&gt;compensate for the model&lt;/em&gt;: context massaging, retry phrasing, output parsing, the clever loop tweaks. Pile two exists to &lt;em&gt;protect you from the model&lt;/em&gt;: permissions, evaluators, provenance types, the audit log. Pile one depreciates with every model release — that's the loan structure I wrote about in the coding-speedup post &amp;lt;!-- TODO: link "Your AI Coding Speedup Is a Loan" --&amp;gt;, and automated harness search will only accelerate the depreciation, since it rediscovers those tricks for cents on the engineer-dollar. Pile two appreciates, because the more capable and self-modifying the system, the more the trust boundary is the only part you actually own.&lt;/p&gt;

&lt;p&gt;STOP's capability-threshold result cuts both ways here and closes the argument neatly: below the threshold, the loop can't help itself; above it, the loop starts probing the checker. Either way, the invariants aren't optional.&lt;/p&gt;

&lt;p&gt;Read &lt;a href="https://lilianweng.github.io/posts/2026-07-04-harness/" rel="noopener noreferrer"&gt;Weng's survey&lt;/a&gt; — it's the best map of this territory right now. Then go look at what your agents are already writing into their own context for tomorrow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Papers referenced: &lt;a href="https://lilianweng.github.io/posts/2026-07-04-harness/" rel="noopener noreferrer"&gt;Weng 2026 (survey)&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2505.22954" rel="noopener noreferrer"&gt;DGM — Zhang et al. 2025&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2606.09498" rel="noopener noreferrer"&gt;Self-Harness — Zhang et al. 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2603.28052" rel="noopener noreferrer"&gt;Meta-Harness — Lee et al. 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2310.02304" rel="noopener noreferrer"&gt;STOP — Zelikman et al. 2023&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2510.04618" rel="noopener noreferrer"&gt;ACE — Zhang et al. 2025&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2605.27276" rel="noopener noreferrer"&gt;SIA — Hebbar et al. 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2601.03315" rel="noopener noreferrer"&gt;Trehan &amp;amp; Chopra 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2601.11868" rel="noopener noreferrer"&gt;Terminal-Bench 2.0 — Merrill et al. 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2605.26340" rel="noopener noreferrer"&gt;ScientistOne — Meng et al. 2026&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2511.16072" rel="noopener noreferrer"&gt;Bubeck et al. 2025&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>My Strawman Baseline Beat My Own Scheme on Half the Gate Classes</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Mon, 06 Jul 2026 11:48:40 +0000</pubDate>
      <link>https://dev.to/p0rt/my-strawman-baseline-beat-my-own-scheme-on-half-the-gate-classes-177h</link>
      <guid>https://dev.to/p0rt/my-strawman-baseline-beat-my-own-scheme-on-half-the-gate-classes-177h</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/p0rt/your-provenance-vector-dies-at-the-storage-boundary-4cc"&gt;Part 4&lt;/a&gt; ended with a question I couldn't answer: has anyone actually measured what gate decisions do on the reconstructed provenance vector versus the original? Not argued from first principles. Measured.&lt;/p&gt;

&lt;p&gt;Nobody in the comments had data. Neither did I. So I built the harness: &lt;a href="https://github.com/P0rt/provenance-compaction-lab" rel="noopener noreferrer"&gt;provenance-compaction-lab&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four arms, one oracle
&lt;/h2&gt;

&lt;p&gt;Four provenance-tracking arms observe the &lt;em&gt;same&lt;/em&gt; synthetic trajectory — same seed, same degradation events, same merges. They differ only in what happens to provenance between decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ground_truth&lt;/strong&gt; — full vector, full lineage, never compacted. The oracle everything else is judged against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structural_min&lt;/strong&gt; — the Part 4 scheme. Axis scores keep their running min. Every C steps, lineage truncates to the last K hops; taint ids attached in the folded prefix are dropped, only a count survives. The compression penalty multiplies into &lt;code&gt;reconstruction&lt;/code&gt;, which is folded into the min like any other axis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structural_perhop&lt;/strong&gt; — identical, except &lt;code&gt;reconstruction&lt;/code&gt; is never min-folded. It's carried structurally as &lt;code&gt;(n_compactions, worst_penalty)&lt;/code&gt; and handed to gates as data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prose&lt;/strong&gt; — the honest-but-naive baseline, not a proposal. Every C steps an LLM summarizes the working state, provenance included, into ≤150 words; a second call extracts scores and taints back out. Whatever survives the round trip is all this arm knows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Merge is unchanged since &lt;a href="https://dev.to/p0rt/trust-isnt-a-scalar-typed-provenance-for-agent-chains-229p"&gt;Part 3&lt;/a&gt; — element-wise min:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Iterable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ProvenanceVector&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ProvenanceVector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Merge = element-wise min across inputs (Part 3).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge() needs at least one input vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ProvenanceVector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AXES&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;Every 5 steps, nine gate policies — score thresholds, reconstruction-coupled, lineage blocklist, lineage allowlist, several flagged irreversible — fire against all four arms, and every disagreement with the oracle is logged. The matrix: 500-step horizons, cadences C ∈ {10, 25, 50}, K=5, allowlist window W=8 (&amp;gt; K deliberately, so the starvation shows), reconstruction penalty 0.02 per compaction, three degradation profiles, 20 seeds per cell. All four hypotheses were written into the spec before the first line of code. The whole mock matrix reruns in seconds, deterministic per seed.&lt;/p&gt;

&lt;p&gt;One limitation up front: the trajectories are synthetic. The generator is the component you're meant to swap for your own traces — more on that at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of one false-proceed
&lt;/h2&gt;

&lt;p&gt;Before any aggregate number, here is one decision going wrong, hop by hop.&lt;/p&gt;

&lt;p&gt;Seed 0, C=25, med profile. Decision at step 25, gate &lt;code&gt;payment_no_untrusted_taint&lt;/code&gt; — irreversible, blocks on &lt;code&gt;unverified_web&lt;/code&gt; and &lt;code&gt;tool_flaky&lt;/code&gt; taints. The working value's full taint history at step 25, which is what the oracle sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;taint:unverified_web:2      ← unverified web fetch, step 2
taint:tool_flaky:6          ← flaky tool call, step 6
taint:tool_flaky:7          ← flaky tool call, step 7
taint:unverified_web:11     ← unverified web fetch, step 11
taint:stale_cache:16        ← stale cache read, step 16
taint:fallback_model:24     ← fallback model, step 24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At step 25 the first compaction fires. This is the deliberate lossy step, verbatim from the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;truncate_lineage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValueState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Truncate to the last K hops; fold the prefix into aggregate counts and
    drop the folded taint ids from ``tainted_by`` (only the count survives).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;hops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hops&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hops&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;keep_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;folded_now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;keep_hops&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;keep_hops&lt;/span&gt; &lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;FoldedPrefix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;folded_now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;absorb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;
    &lt;span class="c1"&gt;# invariant for structural arms: tainted_by == union of taints_added over
&lt;/span&gt;    &lt;span class="c1"&gt;# the surviving (visible) hops — folded taint ids are gone.
&lt;/span&gt;    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;taints_added&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lineage truncates to the last K=5 hops. Every taint attached in the folded prefix is gone. What structural_min still sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;taint:fallback_model:24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oracle blocks the payment — four gate-relevant taints. structural_min sees zero gate-relevant taints and proceeds. Nothing in the surviving state even hints that a check was skipped: the fold counter (&lt;code&gt;n_taints_folded&lt;/code&gt;) is the only trace, and a default-allow gate has no reason to look at it.&lt;/p&gt;

&lt;p&gt;Same state under the other two lineage strategies — measured, not hypothetical. &lt;strong&gt;Degrade-to-untrusted&lt;/strong&gt; sees &lt;code&gt;n_taints_folded &amp;gt; 0&lt;/code&gt; and refuses to act; here it happens to be right, but had the folded taints been benign, the same refusal would have been a false-stop. &lt;strong&gt;Rehydrate&lt;/strong&gt; fetches the 21 folded hops of the value's merged ancestry from the append-only log — 2.6 KB read — and recovers the oracle's block exactly.&lt;/p&gt;

&lt;p&gt;That single mechanism, repeated across the matrix, produces the first headline number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline numbers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;3.47%&lt;/strong&gt; of irreversible-action decisions (payment, send) proceeded when the uncompacted oracle said block — structural_min, C=25, med profile.&lt;/li&gt;
&lt;li&gt;structural_min memory &lt;strong&gt;dies at compaction cycle ≈ 35&lt;/strong&gt;: from that cycle on, every reconstruction-coupled gate blocks permanently (0.98³⁵ ≈ 0.493 &amp;lt; 0.5). structural_perhop never dies.&lt;/li&gt;
&lt;li&gt;Prose-vs-structural flip ratio: &lt;strong&gt;1.39×&lt;/strong&gt; — prose disagrees with the oracle on 9.97% of decisions, structural_min on 7.16%, all gates pooled.&lt;/li&gt;
&lt;li&gt;Rehydration: &lt;strong&gt;459 cold-storage lookups per 100 lineage-gate decisions&lt;/strong&gt; buy the flip rate down from 8.01% to 0.00%.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One sort before the details, because that list mixes two kinds of number. The death cycle is arithmetic in a lab coat — 0.98³⁵ &amp;lt; 0.5 needs a calculator, not a harness — and the error polarity two sections down follows from the gate definitions once you state them. For those, the harness contributes the price tag, not the fact. Numbers 1, 3 and 4 are measurements: nothing in the setup forces 3.47% rather than 12%, prose losing overall while winning two gate classes, or rehydration costing 4.6 lookups instead of 40.&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%2F2fmhb15bpqng1bd8nveh.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%2F2fmhb15bpqng1bd8nveh.png" alt="False-proceed rate on irreversible gates versus compaction cadence, one line per degradation profile: the more often memory compacts, the more often irreversible actions fire against the oracle's block" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Boyko was right twice
&lt;/h2&gt;

&lt;p&gt;On Part 4, Nazar Boyko made two claims sharp enough to preregister as hypotheses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim one:&lt;/strong&gt; axis scores would track closely between arms; the gates that inspect &lt;em&gt;lineage&lt;/em&gt; are where decisions split. Confirmed — with a disclosure. Score-gate flip rate for both structural arms is 0.00%, and that number is &lt;strong&gt;by construction, not a discovery&lt;/strong&gt;: compaction never touches the running min of the base axes, and the harness property-tests exactly that invariant (base-axis drift against the oracle: 0.0000, at every decision point, every config). What the measurement adds is where divergence concentrates once scores are ruled out: lineage gates flip 8.01% of decisions blind, and reconstruction-coupled gates do worse than that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim two:&lt;/strong&gt; the recursion. If the compression penalty is folded into a running min, &lt;code&gt;reconstruction&lt;/code&gt; decays monotonically toward zero regardless of how clean each individual hop is. The death-spiral run — 5,000 steps, C=25, 200 compactions — confirms it: monotone the whole way down, crossing the 0.5 gate threshold at cycle 35. From that point every reconstruction-coupled gate blocks permanently. The scores are pristine, the memory is technically alive, and nothing is allowed to act on it.&lt;/p&gt;

&lt;p&gt;structural_perhop, which carries &lt;code&gt;(n_compactions, worst_penalty)&lt;/code&gt; instead of folding, posts &lt;strong&gt;0.00%&lt;/strong&gt; flips on the same gates and never dies. Drift says the same thing from another angle: structural_min's only drifting axis is &lt;code&gt;reconstruction&lt;/code&gt;, at 0.2100 MAE against the oracle; perhop holds it to 0.0191.&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%2Fg0tozo60x3o80gctfdt5.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%2Fg0tozo60x3o80gctfdt5.png" alt="Reconstruction axis over compaction cycles: the min-folded variant decays monotonically and crosses the 0.5 gate threshold at cycle 35, while the per-hop variant stays flat indefinitely" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The error direction is a design choice
&lt;/h2&gt;

&lt;p&gt;Both lineage gate styles were in the harness on purpose, and W &amp;gt; K was chosen deliberately so the starvation would show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocklist gates&lt;/strong&gt; — default-allow: "block if any &lt;code&gt;taint:unverified_web:*&lt;/code&gt; appears in &lt;code&gt;tainted_by&lt;/code&gt;." They fail dangerous: &lt;strong&gt;8.50% false-proceeds, 0.00% false-stops&lt;/strong&gt;. The fold forgets taints; the gate finds nothing to object to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlist gates&lt;/strong&gt; — default-deny: "proceed only if lineage proves no fallback hop within the last 8 hops." They fail expensive: &lt;strong&gt;7.53% false-stops, 0.00% false-proceeds&lt;/strong&gt;. The proof gets folded away; the gate refuses.&lt;/p&gt;

&lt;p&gt;Perfect polarization, and it's the same compaction both times. Compaction decides &lt;em&gt;that&lt;/em&gt; you lose information; the gate's default under missing information decides &lt;em&gt;which way&lt;/em&gt; you fail. So pick the polarity per action class: default-deny anything irreversible. You pay in over-blocking. The alternative pays in wired money.&lt;/p&gt;

&lt;p&gt;The direction of this result was predictable from the definitions — that's exactly why it preregistered cleanly. The magnitudes at these degradation rates were not.&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%2F539ao8gai6gpa4anpc88.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%2F539ao8gai6gpa4anpc88.png" alt="Gate agreement with the oracle by gate class and arm: structural arms hold 100 percent on score gates, and the divergence concentrates in reconstruction-coupled and lineage gate classes" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The strawman refuses to die
&lt;/h2&gt;

&lt;p&gt;H4, as preregistered: &lt;em&gt;prose flip rates dominate the structural arms on every gate class.&lt;/em&gt; Verdict: &lt;strong&gt;FAIL.&lt;/strong&gt; Per gate class, mock channel, blind mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gate class&lt;/th&gt;
&lt;th&gt;prose&lt;/th&gt;
&lt;th&gt;structural_min&lt;/th&gt;
&lt;th&gt;structural_perhop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;score&lt;/td&gt;
&lt;td&gt;2.44%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reconstruction&lt;/td&gt;
&lt;td&gt;4.97%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.17%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lineage_blocklist&lt;/td&gt;
&lt;td&gt;6.82%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.50%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8.50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lineage_allowlist&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.41%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.53%&lt;/td&gt;
&lt;td&gt;7.53%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strawman beats the Part 4 scheme on two of four classes. On reconstruction-coupled gates, the min-folded penalty is a bigger source of corruption than the noisy channel — the death spiral punishes structural_min before prose noise catches up. On blocklist gates, deterministic truncation forgets taints at roughly the rate a lossy extractor does.&lt;/p&gt;

&lt;p&gt;My first instinct was that the mock channel was rigged against prose — taint recall was &lt;em&gt;assumed&lt;/em&gt; at 0.6. So I ran the real one: gpt-5-mini, the actual summarize→extract round trip. Here is the blob it produced for the exact six-taint state from the worked example above:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Working memory state: freshness 0.700, capability 0.650, tool_integrity 0.650, verification 0.450, reconstruction 1.000. Attached taints: taint:fallback_model:24, taint:stale_cache:16, taint:tool_flaky:6, taint:tool_flaky:7, taint:unverified_web:11, taint:unverified_web:2. Overall, the memory is moderately fresh and moderately capable with intact reconstruction, but tool integrity is degraded and verification is low, indicating reduced trust in outputs that relied on tools or external content. Multiple taints note use of a fallback model, stale cache data, two instances of flaky tools, and two unverified web sources; these should be considered when using or revalidating this state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Extraction recovered all five scores exactly and all six taint ids. Across 993 extractions: 0 parse failures, taint recall 0.893, precision 0.904 (245 fabricated taints), score MAE ≈ 0.0001. A modern small model copies explicit values through prose nearly losslessly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parameter&lt;/th&gt;
&lt;th&gt;mock (assumed)&lt;/th&gt;
&lt;th&gt;gpt-5-mini (measured)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;score noise&lt;/td&gt;
&lt;td&gt;gaussian σ = 0.08&lt;/td&gt;
&lt;td&gt;≈ 0.0001 MAE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;taint recall&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;0.893&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;taint precision&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.904&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flip rate, score gates&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;td&gt;0.08%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flip rate, reconstruction gates&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flip rate, allowlist gates&lt;/td&gt;
&lt;td&gt;29.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;43.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Disclosure on this table before the punchline: the real column is a smaller slice — C ∈ {10, 25}, med profile only, 2 seeds, those 993 extractions — while the mock column is the full matrix. Read it as channel characterization, not a matched benchmark; the repo ships a matched-slice config if you want the strict twin. (The matched slice also doubles as a sanity check: the structural arms' columns come out identical between mock and real runs, because they never touch the channel — the only column that actually moves is prose.)&lt;/p&gt;

&lt;p&gt;The last row is the point. The channel got nearly perfect, and allowlist flips got &lt;em&gt;worse&lt;/em&gt;. A summary preserves values; it destroys structure. "No fallback hop within the last 8 hops" is a proof about an ordered window, and no amount of faithful prose reconstitutes the window. &lt;strong&gt;The failure mode of prose isn't noise — it's the loss of provability.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Caveat, because it matters: this measured the channel in a best case. The summarize prompt hands the model a clean structured list and asks it to carry the list across. Real agent memory interleaves provenance with content competing for the same 150 words. Treat the mock's 0.6 recall as the pessimistic bound and gpt-5-mini's 0.893 as the optimistic one — on structure-dependent gates, both bounds tell the same story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the crossover sits
&lt;/h2&gt;

&lt;p&gt;"Prose sometimes beats structural_min" is a result. A design rule needs to know &lt;em&gt;when&lt;/em&gt;. A finer cadence sweep, C from 5 to 100, med profile, 20 seeds per point, settles it.&lt;/p&gt;

&lt;p&gt;On reconstruction-coupled gates, structural_min is worse than the prose strawman at every cadence up to C=50 — at C=10 that's 34.92% flips against prose's 6.85% — and only drops below prose between C=50 and C=75. The rule that falls out: &lt;strong&gt;once memory sees more than ~7–10 compaction cycles inside a decision horizon, min-folded reconstruction — not summarization noise — is the dominant corruption source.&lt;/strong&gt; On blocklist gates the cross comes even earlier, around C=15. Pooled over all gate classes, prose stays behind at every cadence — allowlist starvation and score noise keep it there.&lt;/p&gt;

&lt;p&gt;Before anyone quotes that bolded rule as a constant: it isn't one. The threshold is a function of two parameters I chose — the per-compaction penalty (0.02 here) and the noise of the channel it races. The death cycle is closed-form: memory dies at n = ln(θ) / ln(1 − p), which at θ = 0.5, p = 0.02 gives 34.3 — the first whole cycle below threshold is 35, matching the run. That part scales as 1/p: double the penalty, halve the death cycle. Arithmetic.&lt;/p&gt;

&lt;p&gt;The crossover against prose does &lt;em&gt;not&lt;/em&gt; scale as 1/p — and I know because I assumed it would and swept it: 14 cadences × 5 penalties × 20 seeds, 1,400 mock runs, under a minute. The fit says the crossover cycle count moves as ~p^−1.6, steeper than 1/p, because the baseline it races isn't flat: prose noise compounds with cycle count too, just slower, so as the penalty shrinks, the intersection runs away superlinearly. (Exponent fitted on the sweep grid, p from 0.005 to 0.1 — don't carry it far outside.) &lt;code&gt;prov-lab report&lt;/code&gt; prints the analytic death cycle for whatever penalty you configure, and &lt;code&gt;prov-lab sweep&lt;/code&gt; maps your crossover — so the rule you quote can be yours, not mine.&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%2Fw9r9zxkenvfmu6r4bwpi.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%2Fw9r9zxkenvfmu6r4bwpi.png" alt="Crossover cadence between structural_min and the prose channel as a function of the reconstruction penalty, log axes, with the fitted power law near p to the minus 1.6" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which exposes what H4 got wrong at preregistration time: "structural vs prose" was never the axis. The axis is &lt;em&gt;which fields the compaction preserves, relative to which fields the gates read&lt;/em&gt;. Every arm in this experiment failed exactly where it discards something some gate consumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quimby's question has a price tag
&lt;/h2&gt;

&lt;p&gt;Max Quimby asked, on Part 4: when lineage has been compressed and a policy needs the detail — do you re-expand from somewhere, or does the policy degrade to treating the result as untrusted? Both answers are in the harness, with deciding blind as the control. The irreversible payment gate, three ways:&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;agreement&lt;/th&gt;
&lt;th&gt;false-proceed&lt;/th&gt;
&lt;th&gt;false-stop&lt;/th&gt;
&lt;th&gt;lookups / 100 decisions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;blind&lt;/td&gt;
&lt;td&gt;91.22%&lt;/td&gt;
&lt;td&gt;8.78%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;degrade&lt;/td&gt;
&lt;td&gt;95.72%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;4.28%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rehydrate&lt;/td&gt;
&lt;td&gt;100.00%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;639.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aggregated across all lineage gates: 459 lookups per 100 decisions, tens of KB read, flip rate 8.01% → 0.00%. The gate with all three modes, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GateView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hop_log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HopLog&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GateDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;taints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lookups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;bytes_read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;detail_missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_taints_folded&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;degrade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;detail_missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# degrade-to-untrusted: taints were dropped, refuse to act
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;GateDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rehydrate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;detail_missing&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hop_log&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
            &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hop_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded_hop_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;lookups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folded_hop_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;taints&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;taints_added&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_matches&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_prefixes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;taints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;GateDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lookups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lookups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bytes_read&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Degrade costs nothing and converts every dangerous error into an expensive one — a legitimate answer for reversible actions. Rehydration from an append-only hop log recovers the oracle &lt;em&gt;exactly&lt;/em&gt;, at a price that turned out measurable and small: about 4.6 lookups per lineage-gate decision. For irreversible gates, that's the trade I'd take every time.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persist running-min axis scores.&lt;/strong&gt; Constant size, lossless by construction, drift 0.0000 at every decision point — property-tested. This half of Part 4 survives contact with measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never fold a compression penalty into a min.&lt;/strong&gt; Track &lt;code&gt;(n_compactions, worst_penalty)&lt;/code&gt; structurally. perhop flips 0.00% of reconstruction-coupled decisions and never dies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick gate polarity per action class.&lt;/strong&gt; Default-deny anything irreversible: fail expensive, not dangerous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append-only hop log plus rehydrate-on-demand&lt;/strong&gt; for irreversible gates; degrade-to-untrusted is fine for reversible ones. A reference implementation on stdlib sqlite3 ships in the repo as &lt;code&gt;provlab.store&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your own pipeline.&lt;/strong&gt; The real-channel run was ~2,000 requests to a small model — $1–1.5 and about 19 minutes. Less than a coffee.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nb"&gt;sync
&lt;/span&gt;uv run prov-lab run &lt;span class="nt"&gt;--config&lt;/span&gt; experiments/config.yaml &lt;span class="nt"&gt;--mock&lt;/span&gt;
uv run prov-lab report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whole matrix in seconds, deterministic per seed, MIT: &lt;a href="https://github.com/P0rt/provenance-compaction-lab" rel="noopener noreferrer"&gt;P0rt/provenance-compaction-lab&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two ways to point this at your own system now. &lt;code&gt;prov-lab audit&lt;/code&gt; is the closing question as a command: ~20 lines of YAML — which fields your compaction preserves, which fields each gate reads, each gate's default polarity — and it prints the starvation table: which of your gates are deciding blind, and in which direction they'll fail. No simulation, no traces, five minutes. And &lt;code&gt;prov-lab run --trace your.jsonl&lt;/code&gt; replays the whole harness over real agent logs: taint-derivation rules are YAML data, not code (tool status ≠ ok → taint, cache age over threshold → taint, and so on), the oracle is a full-provenance replay of the same trace, and the report tells you what it could and couldn't map. Synthetic trajectories are this experiment's honest limitation; replication on a real memory pipeline is the one result I can't produce alone.&lt;/p&gt;

&lt;p&gt;Credits, which in this series means authorship: &lt;strong&gt;Nazar Boyko&lt;/strong&gt; called both the score/lineage split and the min-fold recursion before a line of this code existed. &lt;strong&gt;Max Quimby&lt;/strong&gt; asked the question that became a price tag. This part, like the ones before it, was co-written by the comment section.&lt;/p&gt;

&lt;p&gt;So, the question for this thread: &lt;strong&gt;what does your compaction actually preserve, relative to what your gates read?&lt;/strong&gt; &lt;code&gt;prov-lab audit&lt;/code&gt; is that question as a command; &lt;code&gt;--trace&lt;/code&gt; is the full version. Either way — post the table.&lt;/p&gt;

&lt;p&gt;Part 6 is &lt;code&gt;attest()&lt;/code&gt; — restoration semantics. Everything in this system can only lower an axis. What event is allowed to raise one, and who holds the authority to say so?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>agents</category>
    </item>
    <item>
      <title>Your Provenance Vector Dies at the Storage Boundary</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Wed, 01 Jul 2026 11:58:09 +0000</pubDate>
      <link>https://dev.to/p0rt/your-provenance-vector-dies-at-the-storage-boundary-4cc</link>
      <guid>https://dev.to/p0rt/your-provenance-vector-dies-at-the-storage-boundary-4cc</guid>
      <description>&lt;p&gt;Last post I argued that agent trust should be a &lt;a href="https://dev.to/p0rt/trust-isnt-a-scalar-typed-provenance-for-agent-chains-229p"&gt;typed provenance vector&lt;/a&gt;: carry what-degraded-and-how alongside each result, propagate it, let each consumer apply its own policy. The comments agreed on the model and then immediately found the two places it breaks in the real world. Both are load-bearing, both were things I hand-waved, and this post is about them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mote&lt;/strong&gt; asked what happens when the agent runs 500 steps and the vector no longer fits in the context window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mykola&lt;/strong&gt; said the quiet part louder: &lt;em&gt;"you can build a perfect trust lattice but most agents just act on output without checking provenance. The hard part is enforcement, not the model."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are right, and together they name the two ways a provenance vector dies in production: nobody reads it, or it can't survive being stored. One problem is about &lt;em&gt;enforcement&lt;/em&gt;, the other about &lt;em&gt;persistence&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Two failure modes kill a provenance vector in production. &lt;strong&gt;Enforcement:&lt;/strong&gt; if acting on a value doesn't &lt;em&gt;require&lt;/em&gt; passing through the gate, developers (and models writing tool calls) will skip it — so make the unsafe path unrepresentable via types, not discipline. &lt;strong&gt;Persistence:&lt;/strong&gt; on long-horizon agents the vector must survive compression to fit bounded memory, and naive summarization washes out exactly the axes you need — so compress structurally (per-axis, lossless scores + lossy lineage), not as prose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Problem 1: enforcement, or the vector nobody reads
&lt;/h2&gt;

&lt;p&gt;Mykola's point is the one that should scare you, because it's true of almost every "add metadata to make it safer" scheme: the metadata is optional, so under deadline it gets skipped. You can ship a beautiful &lt;code&gt;Provenance&lt;/code&gt; type and six months later find that the payment path reads &lt;code&gt;result.value&lt;/code&gt; and never touches &lt;code&gt;result.provenance&lt;/code&gt;. The lattice was perfect. Nobody consulted it.&lt;/p&gt;

&lt;p&gt;The fix is not "remember to check." Discipline doesn't scale and it definitely doesn't survive a model writing its own tool calls. The fix is to make &lt;em&gt;acting without checking&lt;/em&gt; something the code physically cannot express.&lt;/p&gt;

&lt;p&gt;This is a solved problem in a neighboring field, and it's worth stealing wholesale. Capability-based security has done this for decades: authority is an &lt;strong&gt;unforgeable token you must hold a reference to&lt;/strong&gt; — you can't perform the action without possessing the capability, and possession is the check. Recent work brings this into static types explicitly: track the capability in the type system, and the &lt;em&gt;absence&lt;/em&gt; of it in a function's type guarantees, at compile time, that the function can't perform the guarded action. The safety isn't a runtime assertion you might forget — it's a property of what typechecks.&lt;/p&gt;

&lt;p&gt;Applied to provenance, the move is: &lt;strong&gt;the irreversible action can't accept a raw value, only a gated one.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Generic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TypeVar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NoReturn&lt;/span&gt;
&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TypeVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;T&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Provenanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Generic&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A value you cannot use for a side effect without unwrapping —
    and the ONLY unwrap path runs the gate.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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;prov&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_prov&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prov&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unwrap_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_prov&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proceed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ProvenanceViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_prov&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# refetch / escalate / ...
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_value&lt;/span&gt;

&lt;span class="c1"&gt;# the side-effecting function's SIGNATURE refuses raw values:
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Provenanced&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unwrap_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the only way to get the Money out
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "charge the card without checking provenance" doesn't fail code review — it doesn't typecheck. There is no path from a raw &lt;code&gt;Money&lt;/code&gt; to &lt;code&gt;charge_card&lt;/code&gt;, because the signature demands &lt;code&gt;Provenanced[Money]&lt;/code&gt;, and the only way to extract the value runs the gate. You've moved the enforcement from the developer's memory into the type system. It's the same trick as idempotency keys from two posts ago: don't ask people to remember the safe thing, make the unsafe thing unrepresentable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest limit&lt;/strong&gt; (which a commenter will rightly raise, so I'll raise it first): this holds at the &lt;em&gt;framework boundary&lt;/em&gt;, in typed code you control. The moment your agent writes free-form tool calls — the model generating Python that calls your API directly — it can simply not use the wrapper, and you're back to enforcement-by-hope. For that case the type system can't reach, so enforcement has to drop to the infrastructure layer: the side-effecting tools sit behind a proxy that refuses any call whose payload doesn't carry valid provenance. You lose compile-time guarantees and get runtime rejection instead — worse, but still "structurally can't skip it" rather than "please remember." The principle survives even when the mechanism changes: enforcement lives in something the actor can't route around, never in something it's asked to honor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: provenance that survives compression
&lt;/h2&gt;

&lt;p&gt;mote's problem is deeper and I didn't have an answer in the thread, so I went and found one. Here's the setup: a long-horizon agent — mote's case is literally robots on edge hardware with a hard context ceiling — can't hold a growing provenance graph in working memory across 500 steps. It has to compress. And the standard compression move, summarize-history-into-prose, is catastrophic for provenance specifically, because summarization is &lt;em&gt;lossy in an uncontrolled way&lt;/em&gt; — it'll happily drop "step 47 ran on a stale cache" to save tokens, and that's the one fact a downstream gate needed.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical. The field now attributes the majority of enterprise agent failures to context drift and memory loss during multi-step reasoning — not to hitting the context limit, but to the &lt;em&gt;quality degradation on the way there&lt;/em&gt;. And there's a subtler trap the RL-agent researchers named: compression credit is causally entangled — the same downstream failure needs opposite explanations depending on whether the bad state came from a tool or from memory. If your compression flattens that distinction, you can't even diagnose what broke.&lt;/p&gt;

&lt;p&gt;So the naive answer — "summarize the provenance too" — reintroduces the exact scalar-collapse problem from the last post, now smuggled in through the storage layer. A summary is an average wearing a trench coat.&lt;/p&gt;

&lt;p&gt;The better answer comes from a simple observation: &lt;strong&gt;the axes have different compression economics, so don't compress them uniformly.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scores compress to almost nothing, losslessly.&lt;/strong&gt; A per-axis float — &lt;code&gt;freshness: 0.2, capability: 0.6&lt;/code&gt; — is a handful of numbers. Even across 500 steps, if you keep only the &lt;em&gt;running minimum per axis&lt;/em&gt; (which is what the gate reads anyway; recall the &lt;code&gt;min&lt;/code&gt; from last post), that's constant size regardless of history length. You never need to compress the scores, because &lt;code&gt;min&lt;/code&gt;-reduction already bounds them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lineage is what explodes, and lineage is what you can afford to lose.&lt;/strong&gt; The &lt;code&gt;tainted_by&lt;/code&gt; sets — &lt;em&gt;which exact steps&lt;/em&gt; degraded each axis — grow with the trajectory. But for the &lt;em&gt;gate decision&lt;/em&gt;, you usually don't need the full ancestry; you need "is any unverified degraded step still on the live path." So this is the part you lossy-compress: keep the axis scores whole, summarize the lineage behind a pointer, and accept that you lose "which exact step" while keeping "how degraded, per axis."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This maps onto where the research is heading. The most promising long-horizon approaches have stopped treating the trajectory as prose to be summarized and started treating it as a &lt;strong&gt;typed dependency graph the agent annotates as it works&lt;/strong&gt;, with a deterministic eviction policy that walks the graph when the token budget blows — explicitly to avoid the four pathologies of prose compaction: unpredictable lossiness, structural destruction, blocking cost, and compression-induced hallucination. A typed provenance vector &lt;em&gt;is&lt;/em&gt; that annotation. The eviction policy for provenance is: evict lineage detail, never evict axis scores.&lt;/p&gt;

&lt;p&gt;There's one more axis this forces you to add, and it's almost funny: &lt;strong&gt;compression is itself a degradation source.&lt;/strong&gt; A vector reconstructed from a lossy summary is less trustworthy than one carried whole — so "this provenance was reconstructed across a storage boundary" is a real provenance fact that deserves its own axis. &lt;code&gt;reconstruction: 0.8&lt;/code&gt; means "these scores survived a compaction; treat the lineage as approximate." The provenance system has to describe its own lossiness. Turtles, but only two deep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps being a security problem in disguise
&lt;/h2&gt;

&lt;p&gt;Every post in this series has ended up borrowing from security, and this one makes the reason explicit. Traditional taint tracking assumes deterministic program states and exact data-flow: memory locations, registers, string matches. LLM agents break all of that — untrusted content gets &lt;em&gt;rewritten, summarized, and used to choose later actions&lt;/em&gt;, so "did this bad input reach that sink" is a question about semantic and causal influence, not byte-level flow. The agent security researchers building taint trackers for exactly this case had to redefine propagation to include semantic transformation and cross-session persistence through memory — which is the same two problems this post is about (enforcement and persistence), arrived at from the attack side instead of the reliability side.&lt;/p&gt;

&lt;p&gt;That convergence is the tell. When the reliability people and the security people independently reinvent the same structure — unforgeable gating plus provenance that survives memory — it's because it's the actual shape of the problem, not a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the series stands
&lt;/h2&gt;

&lt;p&gt;Four posts, one arc:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Availability&lt;/strong&gt; — agents fail on capacity (rate limits), not reasoning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctness&lt;/strong&gt; — the capacity fixes buy uptime by acting on unearned output; you need &lt;em&gt;correct&lt;/em&gt; uptime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model&lt;/strong&gt; — trust isn't a scalar; it's a typed provenance vector with policy at the consumer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reality&lt;/strong&gt; (this one) — that vector only works if it's &lt;em&gt;unskippable&lt;/em&gt; (enforcement by type/proxy) and &lt;em&gt;survivable&lt;/em&gt; (structural compression, not prose).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The through-line, one more time: agent reliability is a provenance problem, and provenance is a solved discipline — capability security, data lineage, taint analysis — that we're re-deriving because the untraceable thing now acts, and acts through a bounded, forgetful, non-deterministic memory. The novelty isn't the primitives. It's that they now have to hold under compression and under a model that can route around anything you merely &lt;em&gt;ask&lt;/em&gt; it to respect.&lt;/p&gt;

&lt;p&gt;If you're building this: gate at a boundary the actor can't skip (type or proxy), compress scores losslessly and lineage lossily, and add a &lt;code&gt;reconstruction&lt;/code&gt; axis the day your provenance crosses a storage line. Start there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Credit, again, to the comment section that wrote the spec: **mote&lt;/em&gt;* (compression across the storage boundary, the edge/bounded-context framing that motivates the whole second half), &lt;strong&gt;Mykola Kondratiuk&lt;/strong&gt; (enforcement is the hard part, not the model), plus &lt;strong&gt;Tae Kim&lt;/strong&gt;, &lt;strong&gt;Nazar Boyko&lt;/strong&gt;, &lt;strong&gt;Ken&lt;/strong&gt;, and &lt;strong&gt;Ahmet Özel&lt;/strong&gt; for sharpening the axis rules in the last thread. Open question for this one: has anyone actually run provenance across a compaction boundary in production and measured what the gate decisions do on the reconstructed vector versus the original? That's the experiment I don't have data for yet — and it's the one that decides whether any of this holds.*&lt;/p&gt;

&lt;h3&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2603.00991" rel="noopener noreferrer"&gt;"Tracking Capabilities for Safer Agents"&lt;/a&gt; — capabilities as unforgeable tokens tracked in static types; compile-time non-interference from the absence of a capability.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2604.23374" rel="noopener noreferrer"&gt;"Ghost in the Agent: Redefining Information Flow Tracking for LLM Agents" (NeuroTaint)&lt;/a&gt; — why classical taint doesn't transfer: agents rewrite, summarize, and act on untrusted content; taint as semantic/causal/persistent influence.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2606.11213" rel="noopener noreferrer"&gt;"Beyond Compaction: Structured Context Eviction for Long-Horizon Agents"&lt;/a&gt; — annotate the trajectory as a typed dependency graph; deterministic graph-walking eviction instead of prose summarization.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://zylos.ai/research/2026-02-28-ai-agent-context-compression-strategies/" rel="noopener noreferrer"&gt;"AI Agent Context Compression: Strategies for Long-Running Sessions"&lt;/a&gt; — context drift/memory loss as the majority of enterprise agent failures; anchored iterative summarization beats full reconstruction.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2606.16285" rel="noopener noreferrer"&gt;"HiMPO: Hindsight-Informed Memory Policy Optimization"&lt;/a&gt; — causally entangled memory credit: the same failure needs opposite explanations depending on tool-vs-memory origin.&lt;/li&gt;
&lt;li&gt;The series: &lt;a href="https://dev.to/p0rt/your-ai-agent-isnt-failing-because-it-hallucinates-its-failing-because-of-rate-limits-2d60"&gt;Part 1 — capacity&lt;/a&gt; · &lt;a href="https://dev.to/p0rt/you-fixed-the-rate-limits-now-your-agent-fails-quietly-3keo"&gt;Part 2 — correct uptime&lt;/a&gt; · &lt;a href="https://dev.to/p0rt/trust-isnt-a-scalar-typed-provenance-for-agent-chains-229p"&gt;Part 3 — typed provenance&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Trust Isn't a Scalar: Typed Provenance for Agent Chains</title>
      <dc:creator>Sergei Parfenov</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:21:08 +0000</pubDate>
      <link>https://dev.to/p0rt/trust-isnt-a-scalar-typed-provenance-for-agent-chains-229p</link>
      <guid>https://dev.to/p0rt/trust-isnt-a-scalar-typed-provenance-for-agent-chains-229p</guid>
      <description>&lt;p&gt;Two posts ago, in &lt;a href="https://dev.to/p0rt/you-fixed-the-rate-limits-now-your-agent-fails-quietly-3keo"&gt;the one about agents failing quietly&lt;/a&gt;, I handed you a fix for silent degradation: tag a degraded output &lt;code&gt;trust="degraded"&lt;/code&gt;, propagate the taint down the chain, and gate irreversible actions on it. Clean, shippable, and — as a commenter named Theo pointed out within a day — &lt;strong&gt;wrong in a way that matters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tag was a boolean. And trust isn't a boolean. It isn't even a scalar.&lt;/p&gt;

&lt;p&gt;This post is me being wrong in public and fixing it, because the corrected model is genuinely better and most of it was built by people in that comment thread. Credits at the end; they earned them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A single trust score (&lt;code&gt;full&lt;/code&gt;/&lt;code&gt;degraded&lt;/code&gt;, or &lt;code&gt;0.0–1.0&lt;/code&gt;) collapses on real chains, because degradation happens along &lt;em&gt;different axes&lt;/em&gt; — a stale cache lowers &lt;em&gt;freshness&lt;/em&gt;, a weaker fallback lowers &lt;em&gt;capability&lt;/em&gt; — and different downstream steps care about different ones. Collapse them to one number and you either over-reject (every degradation is fatal) or under-reject (the dangerous one gets averaged away). What actually composes is &lt;strong&gt;typed provenance&lt;/strong&gt;: carry a vector of what-was-degraded-and-how alongside the result, propagate it across the chain, and let each consumer apply its own policy at the moment it's about to act.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a scalar collapses
&lt;/h2&gt;

&lt;p&gt;Here's the case that broke my boolean, almost verbatim from Theo's comment.&lt;/p&gt;

&lt;p&gt;You have two downstream steps, both consuming an upstream result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;summarization&lt;/strong&gt; step. It tolerates a weaker model just fine, but it must not run on stale data.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;price calculation&lt;/strong&gt;. It's the reverse: it needs current data, but a slightly weaker model doing arithmetic is fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the upstream result came from a fallback model reading a 2-hour-old cache. So it's degraded on &lt;em&gt;both&lt;/em&gt; a capability axis (weaker model) and a freshness axis (old cache). What's your single trust score?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you set it &lt;strong&gt;low&lt;/strong&gt; (treat any degradation as serious), the summarization step over-rejects — it would've been totally fine with the weaker model, but your scalar said "degraded" so it bails or escalates needlessly.&lt;/li&gt;
&lt;li&gt;If you set it &lt;strong&gt;high&lt;/strong&gt; (it's "mostly fine"), the price calc under-rejects — it acts on stale data because the scalar averaged the freshness problem into a number that looked acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no single threshold that's simultaneously right for both consumers, because they're not measuring the same thing. A scalar forces every consumer to share one definition of "trustworthy," and they don't have one. As Theo put it: collapse the vector to one number and you destroy exactly the information the consumer needs to make its own decision.&lt;/p&gt;

&lt;p&gt;This isn't just my comment section talking, either — it's where the field is converging. A recent framework (TrustBench) makes the same move explicitly: rather than reduce trust to a single scalar, keep dimensional scores per trust aspect, and weight them per domain — healthcare prioritizing citation validity and recency, finance prioritizing calculation and compliance. Same shape, arrived at independently. When several people reach for the same structure from different directions, it's usually because the structure is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust is a vector; provenance is what you propagate
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that fixes it, and it starts with a vocabulary correction I owe you: I kept calling the thing "trust." That was the bug in the language, not just the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust is not a property of a value. It's a judgment a consumer makes about a value.&lt;/strong&gt; What the value actually &lt;em&gt;carries&lt;/em&gt; is &lt;strong&gt;provenance&lt;/strong&gt; — the typed record of how it came to be: which model produced it, how fresh its inputs were, which tools ran, what got degraded and along which axis. Trust is what each consumer &lt;em&gt;computes from&lt;/em&gt; that provenance, under its own policy. The price calc and the summarizer look at the same provenance and reach different verdicts, and that's correct, not contradictory.&lt;/p&gt;

&lt;p&gt;So you don't propagate a degraded flag. You propagate a &lt;strong&gt;typed vector&lt;/strong&gt;, and each axis degrades independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;FRESHNESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;freshness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# how current were the inputs
&lt;/span&gt;    &lt;span class="n"&gt;CAPABILITY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# how strong was the model that produced this
&lt;/span&gt;    &lt;span class="n"&gt;TOOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                &lt;span class="c1"&gt;# did the tool calls actually succeed
&lt;/span&gt;    &lt;span class="n"&gt;VERIFICATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verification&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;# was this checked against ground truth
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# per-axis score in [0,1]; 1.0 = fully trusted on that axis
&lt;/span&gt;    &lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="c1"&gt;# which upstream step_ids contributed degradation, per axis
&lt;/span&gt;    &lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;upstreams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Provenance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Provenance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# an output is only as fresh as its stalest input, only as
&lt;/span&gt;            &lt;span class="c1"&gt;# capable as its weakest producer — min, not average. averaging
&lt;/span&gt;            &lt;span class="c1"&gt;# is exactly how the dangerous axis gets washed out.
&lt;/span&gt;            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upstreams&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upstreams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tainted_by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;min&lt;/code&gt; is doing real work there. The whole failure of my original taint-as-boolean was that it answered "is anything degraded?" — a single OR across the chain. The vector answers "&lt;em&gt;what kind&lt;/em&gt; of degradation is this output carrying, and how much, per axis?" — and crucially, it takes the &lt;strong&gt;minimum per axis&lt;/strong&gt; rather than averaging, because averaging is the mathematical operation that makes a serious freshness problem disappear behind three fine capability scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate is per-consumer, not global
&lt;/h2&gt;

&lt;p&gt;Now the irreversibility gate from the last post stops being one global threshold and becomes a policy that lives &lt;em&gt;at each consumer&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# per-axis minimum this consumer requires to act without re-check
&lt;/span&gt;    &lt;span class="n"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;admits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# the summarizer doesn't care about capability, but demands freshness
&lt;/span&gt;&lt;span class="n"&gt;SUMMARIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FRESHNESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CAPABILITY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# the price calc is the mirror image
&lt;/span&gt;&lt;span class="n"&gt;PRICE_CALC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FRESHNESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CAPABILITY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VERIFICATION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proceed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# which axis failed tells you HOW to recover, not just THAT to stop
&lt;/span&gt;    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;action_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;axes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FRESHNESS&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refetch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# re-run the stale step on live data
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;Axis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CAPABILITY&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;re-run-on-primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate-to-human&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the payoff. The same upstream provenance vector flows to both consumers, and they reach &lt;em&gt;different, individually correct&lt;/em&gt; decisions from it. The summarizer proceeds; the price calc refetches. One global score could never do that — and the failed-axis tells you &lt;em&gt;how&lt;/em&gt; to recover, which a boolean never could.&lt;/p&gt;

&lt;p&gt;Notice this also absorbs a point another commenter (Manuel) made independently: he argued the tag should be an enum, not a bool — &lt;code&gt;skipped-tool&lt;/code&gt; vs &lt;code&gt;stale-data&lt;/code&gt; vs &lt;code&gt;retry-budget-exhausted&lt;/code&gt; route differently. He was right, and the vector is the generalization: an enum is a vector with one axis active; the full structure lets multiple axes degrade at once, which is the real production case.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Gate on risk, not confidence" — and confidence is just one axis
&lt;/h2&gt;

&lt;p&gt;The last post argued you should gate on &lt;em&gt;irreversibility&lt;/em&gt;, not on the model's self-reported confidence. The vector makes that precise instead of hand-wavy: &lt;strong&gt;confidence is one axis among several, and it's the one the model grades itself on.&lt;/strong&gt; A model can be 95%-confident (high on a confidence axis) while sitting on a freshness score of 0.2 because it reasoned over a stale cache. The skill-conditional-trust literature makes the same argument from the routing side — a single global score is the wrong object because it can't express "great at this, useless at that." Confidence-as-the-only-axis is how you get the war story everyone has: the agent that was sure, and sure on the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many axes before it stops being worth it?
&lt;/h2&gt;

&lt;p&gt;This is the honest open question, and the one I asked Theo back. A vector with 40 axes is just a scalar's opposite failure — unwieldy, untunable, theater of rigor. My current answer, and I'd genuinely take pushback: &lt;strong&gt;start with the axes that map to your actual degradation sources, and no more.&lt;/strong&gt; If your system has exactly two ways to degrade — fallback model and stale cache — you have two axes (capability, freshness). Add &lt;code&gt;verification&lt;/code&gt; the moment you have a re-check step whose result you want to carry. Add &lt;code&gt;tool&lt;/code&gt; when a tool can half-succeed. The axis count should equal the number of &lt;em&gt;distinct things that can independently go wrong&lt;/em&gt;, not the number of things you can imagine going wrong. If two "axes" always move together, they're one axis.&lt;/p&gt;

&lt;p&gt;The sweet spot, I think, is the smallest set where each axis maps to a different &lt;em&gt;recovery action&lt;/em&gt;. Freshness → refetch. Capability → re-run on primary. Verification → escalate. If two axes would trigger the same recovery, collapse them. The vector earns its complexity only where it changes what you &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical layer (mostly stolen from the comments)
&lt;/h2&gt;

&lt;p&gt;The vector is the core idea, but the thread surfaced a full toolkit around it, and it'd be dishonest to present any of it as mine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admission control, upstream of everything&lt;/strong&gt; (Dan): before the agent fans out, decide if the whole task can afford to run, and separate the four limits that 429s blur together — provider quota (physics), account quota (policy), task budget (this run), ledger (forensics). The ledger turns out to be the same record as provenance: "this run cost 47 calls, 12 on the fallback tier" is both your bill and your capability-axis score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation at consumption, not production&lt;/strong&gt; (James): don't validate on the fresh-call path and trust the cache; validate when a value is &lt;em&gt;used&lt;/em&gt;, regardless of where it came from. That closes the laundering loophole at the consumer — which is exactly where the per-consumer gate already lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-bound by causality, not wall-clock&lt;/strong&gt; (HARD IN SOFT OUT): I was tempted by "reset taint after N seconds." Don't — degraded state can sleep and surface later. Clear an axis when nothing on the live path still derives from the degraded step, not when a timer expires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The poor-man's version for solo builders&lt;/strong&gt; (TuanAnhNguyen): no observability stack? Have any tool that acts on a stale-readable input append one line to a log, and &lt;code&gt;grep&lt;/code&gt; it before anything irreversible. It's the 5%-effort version of the provenance vector — a breadcrumb instead of a graph — and below a certain scale it's the &lt;em&gt;correct&lt;/em&gt; amount of engineering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The distributed correction&lt;/strong&gt; (Abdullah): my original concurrency cap was an in-process semaphore, which silently assumes one process. Under serverless fan-out, N containers each capping at 8 gives you 8N real concurrency. The limiter has to live outside the workers. (Also: TPM saturates before RPM on long-context agents, and "fallback to a cheaper model" is fiction if it draws from the same pooled tier. Both are capability/freshness axis sources you'd otherwise miss.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The parable that says it better than I did
&lt;/h2&gt;

&lt;p&gt;A commenter (HARD IN SOFT OUT) left this, and it's the whole series in five lines:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The agent hit a rate limit. It fell back to a cached answer from last Tuesday. The world changed on Wednesday. The agent kept working. The logs said "cache hit, 200 OK." The user got a message: "Your order has shipped." The warehouse's API key expired on Thursday.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every hop green. Every log a 200. And a real package never ships. A scalar trust score on that final "order shipped" output would read &lt;em&gt;fine&lt;/em&gt; — the last call succeeded. A provenance vector reads &lt;code&gt;freshness: 0.1, tainted_by: {warehouse_check}&lt;/code&gt; and the shipping gate refuses to fire. That's the entire difference between uptime and correct uptime, and between a boolean and a vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the series
&lt;/h2&gt;

&lt;p&gt;Three posts in, the actual thesis has assembled itself: &lt;strong&gt;agent reliability is a provenance problem.&lt;/strong&gt; Availability (post 1) is the easy axis. Correctness (post 2) is the one that bites. And the structure that makes correctness tractable (post 3) is typed provenance carried through the chain, with policy at the edges. None of that is exotic — it's data lineage, taint analysis, and saga patterns, borrowed from disciplines that solved their version decades ago, newly load-bearing because the untraceable thing now &lt;em&gt;acts&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building this: start with two axes and a &lt;code&gt;min&lt;/code&gt;, put the policy at the consumer, and add an axis only when it changes a recovery action. Everything else is premature.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was largely written by the comments on the last one. Credit, specifically: **Theo Valmis&lt;/em&gt;* (trust-is-a-vector, the summarize-vs-price-calc case, "typed provenance"), &lt;strong&gt;Manuel Bruña&lt;/strong&gt; (enum-not-bool), &lt;strong&gt;Dan&lt;/strong&gt; (admission control, the four-limit split), &lt;strong&gt;James O'Connor&lt;/strong&gt; (validation at consumption), &lt;strong&gt;HARD IN SOFT OUT&lt;/strong&gt; (causality-bound taint, the parable), &lt;strong&gt;TuanAnhNguyen&lt;/strong&gt; (the solo-builder grep version), &lt;strong&gt;Abdullah Shahin&lt;/strong&gt; (the distributed-limiter and pooled-fallback corrections), and &lt;strong&gt;Scarab Systems&lt;/strong&gt; (the "evidence gate" framing that started me thinking about provenance as an obligation, not metadata). Best comment section on this site. Question for the thread: how many axes does your system actually need — and which ones map to a distinct recovery action versus just feeling rigorous?*&lt;/p&gt;

&lt;h3&gt;
  
  
  Sources &amp;amp; further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2603.09157" rel="noopener noreferrer"&gt;"Real-Time Trust Verification for Safe Agentic Actions" (TrustBench)&lt;/a&gt; — dimensional trust scores over a scalar, domain-weighted, with block/warn/proceed gating.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/html/2606.14200" rel="noopener noreferrer"&gt;"When Should Agent Trust Be Conditional?"&lt;/a&gt; — why a single global trust score is the wrong object for skill-heterogeneous agents.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/html/2606.04990" rel="noopener noreferrer"&gt;"From Agent Traces to Trust: A Survey of Evidence Tracing and Execution Provenance in LLM Agents"&lt;/a&gt; — persistent lineage across memory writes, retrievals, and reuse.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.montecarlodata.com/blog-redefining-agent-trust-input-output" rel="noopener noreferrer"&gt;"Redefining AI Agent Trust: An Input/Output-First Approach"&lt;/a&gt;, Monte Carlo — trust as enforced contracts at system boundaries (freshness, schema, lineage on input; traceability on output).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/p0rt/your-ai-agent-isnt-failing-because-it-hallucinates-its-failing-because-of-rate-limits-2d60"&gt;Part 1 — the capacity side&lt;/a&gt; and &lt;a href="https://dev.to/p0rt/you-fixed-the-rate-limits-now-your-agent-fails-quietly-3keo"&gt;Part 2 — correct uptime&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
