<?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: Ikkun</title>
    <description>The latest articles on DEV Community by Ikkun (@ikkun1222).</description>
    <link>https://dev.to/ikkun1222</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%2F4078352%2F0c81ce0e-3272-4e25-8763-0d733bb9806b.png</url>
      <title>DEV Community: Ikkun</title>
      <link>https://dev.to/ikkun1222</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ikkun1222"/>
    <language>en</language>
    <item>
      <title>Jev vs a 310M encoder I trained myself: 750 rows, three tasks, two different winners</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:20:55 +0000</pubDate>
      <link>https://dev.to/ikkun1222/jev-vs-a-310m-encoder-i-trained-myself-750-rows-three-tasks-two-different-winners-242e</link>
      <guid>https://dev.to/ikkun1222/jev-vs-a-310m-encoder-i-trained-myself-750-rows-three-tasks-two-different-winners-242e</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I ran three Japanese classification tasks (250 rows each, same gold labels) through six systems. A 310M encoder I fine-tuned on 250 labels beat TypeSafe's Jev on topic classification by &lt;strong&gt;+12.0 points&lt;/strong&gt; (McNemar p=0.00007) and was &lt;strong&gt;4–20× faster&lt;/strong&gt; — but on the two polarity tasks it only tied Jev. The zero-shot open-source models everyone is writing about lost everywhere: a GLiNER-family classifier scored 62.8 / 87.2 / 44.8 against Jev's 76.8 / 94.4 / 74.0. The same architecture family, trained on my labels, scored 88.8 / 92.8 / 75.2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One-line summary&lt;/strong&gt;: A decision API wins when you have no labels; a small trained encoder wins when you have a few hundred — and which one wins on a given task is decided by the shape of the task, not by which model is trending.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question behind the hype
&lt;/h2&gt;

&lt;p&gt;Jev (TypeSafe AI's System One) is genuinely good, and it is cheap: $0.042 per 1M input tokens with output free, roughly 32k of context, a confidence number in the response, and no training data required. If you read the last two weeks of posts about it, the conclusion is "use Jev."&lt;/p&gt;

&lt;p&gt;I use Jev. But I had a pile of results from my own tasks that did not fit that conclusion, and one of them was embarrassing enough that I re-ran it twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;char n-gram + logistic regression baseline&lt;/strong&gt; beat Jev on long-document topic classification by 11.6 points (0.884 vs 0.768).&lt;/li&gt;
&lt;li&gt;The same baseline lost to Jev by 20.8 points on short-text polarity (0.736 vs 0.944).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 5-minute linear model beating a decision API on one task and losing badly on the next is not a story about either model. It is a story about &lt;strong&gt;task shape&lt;/strong&gt;. So I designed the experiment I should have run first: hold the answers fixed, hold the rows fixed, and separate the two axes that actually differ — &lt;em&gt;labels or no labels&lt;/em&gt;, and &lt;em&gt;topic or meaning&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and how
&lt;/h2&gt;

&lt;p&gt;Three Japanese datasets, 250 rows each, frozen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Classes&lt;/th&gt;
&lt;th&gt;Chance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;livedoor news corpus&lt;/td&gt;
&lt;td&gt;long-document topic (mean 1,174 chars)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;11.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rakuten reviews&lt;/td&gt;
&lt;td&gt;short-text polarity (mean 138 chars)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chabsa&lt;/td&gt;
&lt;td&gt;financial-sentence polarity (mean 92 chars)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six systems, two conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-shot (0 labels)&lt;/strong&gt;: Jev, a local 26B MoE (Gemma 4 26B-A4B, Q4_K_M, resident on the same mini-PC), SemIf (Qwen3.5-4B), GLiClass multilang-mini (a GLiNER-family classifier), Laya (typed decisions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trained (250 labels)&lt;/strong&gt;: &lt;code&gt;sbintuitions/modernbert-ja-310m&lt;/code&gt; with a classification head, and a char n-gram + logistic regression baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trained numbers are &lt;strong&gt;5-fold cross-validated out-of-fold predictions&lt;/strong&gt;: train on 200, predict the held-out 50, five times, so all 250 reported predictions come from a model that never saw that row. That is the same protocol I use for the linear baseline, which means the trained row and the baseline row are directly comparable.&lt;/p&gt;

&lt;p&gt;Everything is compared on &lt;strong&gt;identical row ids&lt;/strong&gt; with &lt;strong&gt;McNemar's test&lt;/strong&gt;. This matters: at n=250 the independent-confidence interval is about ±6 points, so "0.888 vs 0.944" cannot be called a difference without a paired test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;livedoor (9)&lt;/th&gt;
&lt;th&gt;rakuten (2)&lt;/th&gt;
&lt;th&gt;chabsa (3)&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ModernBERT-ja-310m&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;trained, 250 labels&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;75.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.10–0.45s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jev 1.13.0&lt;/td&gt;
&lt;td&gt;zero-shot&lt;/td&gt;
&lt;td&gt;76.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;74.0%&lt;/td&gt;
&lt;td&gt;1.9–2.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 26B-A4B (local)&lt;/td&gt;
&lt;td&gt;zero-shot&lt;/td&gt;
&lt;td&gt;65.2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;75.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemIf (Qwen3.5-4B)&lt;/td&gt;
&lt;td&gt;zero-shot&lt;/td&gt;
&lt;td&gt;68.8%&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;td&gt;64.0%&lt;/td&gt;
&lt;td&gt;1.3–6.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLiClass multilang-mini&lt;/td&gt;
&lt;td&gt;zero-shot&lt;/td&gt;
&lt;td&gt;62.8%&lt;/td&gt;
&lt;td&gt;87.2%&lt;/td&gt;
&lt;td&gt;44.8%&lt;/td&gt;
&lt;td&gt;0.04–0.42s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laya (typed decisions)&lt;/td&gt;
&lt;td&gt;zero-shot&lt;/td&gt;
&lt;td&gt;29.2%&lt;/td&gt;
&lt;td&gt;60.0%&lt;/td&gt;
&lt;td&gt;40.0%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;char n-gram + LogReg&lt;/td&gt;
&lt;td&gt;trained, 250 labels&lt;/td&gt;
&lt;td&gt;88.4%&lt;/td&gt;
&lt;td&gt;73.6%&lt;/td&gt;
&lt;td&gt;64.0%&lt;/td&gt;
&lt;td&gt;~0s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;McNemar, same ids:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;livedoor&lt;/th&gt;
&lt;th&gt;rakuten&lt;/th&gt;
&lt;th&gt;chabsa&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;trained encoder − Jev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+12.0pt (p=0.00007)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−1.6pt (p=0.50)&lt;/td&gt;
&lt;td&gt;+1.2pt (p=0.83)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trained encoder − local 26B MoE&lt;/td&gt;
&lt;td&gt;+23.6pt (p&amp;lt;1e-15)&lt;/td&gt;
&lt;td&gt;−1.6pt (p=0.45)&lt;/td&gt;
&lt;td&gt;±0.0pt (p=1.00)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trained encoder − SemIf&lt;/td&gt;
&lt;td&gt;+20.0pt (p&amp;lt;1e-15)&lt;/td&gt;
&lt;td&gt;+0.8pt (p=0.83)&lt;/td&gt;
&lt;td&gt;+11.2pt (p=0.007)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;trained encoder − GLiClass (zero-shot)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+26.0pt (p&amp;lt;1e-15)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+5.6pt (p=0.016)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+30.4pt (p&amp;lt;1e-15)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trained encoder − char n-gram LogReg&lt;/td&gt;
&lt;td&gt;+0.4pt&lt;/td&gt;
&lt;td&gt;+19.2pt&lt;/td&gt;
&lt;td&gt;+11.2pt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Finding 1: the same family wins or loses depending on whether you trained it
&lt;/h2&gt;

&lt;p&gt;This is the part I did not expect, and it is the most useful thing in the table.&lt;/p&gt;

&lt;p&gt;GLiClass is a GLiNER-family classifier — same lineage as the models currently being pitched as open-source Jev alternatives. Zero-shot, it scored &lt;strong&gt;62.8 / 87.2 / 44.8&lt;/strong&gt;: it lost to Jev on all three tasks, and on the 3-class polarity task it was barely above chance (44.8% against 33.3%).&lt;/p&gt;

&lt;p&gt;Trained on 250 of my labels, the same generation of model scored &lt;strong&gt;88.8 / 92.8 / 75.2&lt;/strong&gt;. The gap between zero-shot and trained is &lt;strong&gt;+26.0 / +5.6 / +30.4 points&lt;/strong&gt;, all significant.&lt;/p&gt;

&lt;p&gt;So "is there an open-source model that replaces Jev?" was the wrong question. The models are not the variable — &lt;strong&gt;the labels are&lt;/strong&gt;. A 310M encoder with 250 labels beats a decision API on one task and ties it on two; the same architecture with zero labels loses to everything.&lt;/p&gt;

&lt;p&gt;I also paid for this lesson twice. Before this experiment I ran a proper evaluation of the two models most often named as Jev replacements — SemIf and Laya — on these same rows. &lt;strong&gt;Both failed to replace anything.&lt;/strong&gt; Laya scored 29.2% on a 9-class task (chance 11.1%) with a median confidence of 0.029, i.e. it was visibly not deciding. Its zero-shot English number in its own documentation is 0.362, so this is not a Japanese-specific defect — it is a zero-shot defect. SemIf was closer but lost on all three tasks, two of them significantly.&lt;/p&gt;

&lt;p&gt;Independent benchmarks showed the same thing. On a frozen-question benchmark, Laya's "hard" score was 34.1% while its model card implied a win over Jev, because the model had been fine-tuned on that benchmark's training split. Self-reported comparisons of fine-tuned models are not evidence about zero-shot behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: training pays on topic tasks and does not on meaning tasks
&lt;/h2&gt;

&lt;p&gt;Look at where the 12-point win came from and where it did not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;livedoor (9-class topic, 1,174 chars)&lt;/strong&gt;: trained &lt;strong&gt;88.8%&lt;/strong&gt; vs Jev 76.8%. The labels are in the text — if you have a few hundred examples, "what topic is this" is a vocabulary problem, and a trained encoder learns the vocabulary. It also beat the char n-gram baseline by a hair (+0.4pt), which tells you the &lt;em&gt;topic signal itself&lt;/em&gt; was already largely lexical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rakuten (2-class polarity, 138 chars)&lt;/strong&gt;: trained 92.8% vs Jev 94.4% — a statistical tie (p=0.50). Same for the local 26B MoE (p=0.45). Adding data does not fix this, because the problem is not "I don't know the vocabulary", it is "I need to judge sentiment".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;chabsa (3-class financial polarity, 92 chars)&lt;/strong&gt;: trained 75.2%, Jev 74.0%, local MoE 75.2%. A three-way tie. On this task &lt;strong&gt;no one is good&lt;/strong&gt; — 74–75% is the ceiling for all three approaches, and that is the real finding. If you need better here, you need a third stage (a stronger model on the low-confidence tail), not a different single model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule I now use: &lt;strong&gt;if the label is visible in the words, train something small; if the label is a judgement about the words, use a decision API; if both plateau around 75%, stop shopping for models and fix the data or add an escalation stage.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the trained encoder is dramatically cheaper to run
&lt;/h2&gt;

&lt;p&gt;Latency, single-threaded, same machine:&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;p50 per item&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;trained encoder (310M, CPU)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.10–0.45s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jev&lt;/td&gt;
&lt;td&gt;1.9–2.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;local 26B MoE&lt;/td&gt;
&lt;td&gt;1.24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemIf (4B)&lt;/td&gt;
&lt;td&gt;1.3–6.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is &lt;strong&gt;4–20× faster than the API&lt;/strong&gt;, with zero marginal cost, no network dependency, and no data leaving the machine. The confidence score is a softmax you can calibrate yourself rather than a vendor's number.&lt;/p&gt;

&lt;p&gt;This is the part that changes architecture. Once the local model is both faster and not worse, the API becomes the &lt;em&gt;fallback&lt;/em&gt; rather than the primary path: trained encoder first, Jev only on the low-confidence tail, the novel-looking rows, and as a drift detector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Because you will ask: can I train it on the GPU in the box?
&lt;/h2&gt;

&lt;p&gt;I tried that too, on the same mini-PC (Ryzen 9 7940HS, Radeon 780M iGPU, &lt;code&gt;gfx1103&lt;/code&gt;). Short answer: &lt;strong&gt;it works, and it is not worth switching to.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Getting PyTorch onto that iGPU is now possible through AMD's device-specific wheels, but there are three traps in a row:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installing with the AMD index as the only index makes pip fail to resolve ordinary dependencies, because you have just replaced PyPI.&lt;/li&gt;
&lt;li&gt;Adding PyPI back as an extra index lets &lt;strong&gt;PyPI's newer torch win the version comparison&lt;/strong&gt; — you silently get a CUDA build and a pile of &lt;code&gt;nvidia-*&lt;/code&gt; packages.&lt;/li&gt;
&lt;li&gt;The nightly index's newest torch requires a &lt;code&gt;rocm-bootstrap&lt;/code&gt; version that the same index does not ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once pinned properly, the iGPU is real: &lt;code&gt;torch 2.8.0+rocm7.14.0a20260519&lt;/code&gt;, &lt;code&gt;arch=gfx1103&lt;/code&gt;, fp16 matmul fine. The microbenchmark is impressive — &lt;strong&gt;1.21 s/step against 16.41 s/step on CPU, 13.6×&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The real workload does not deliver that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task (5-fold CV, 310M encoder)&lt;/th&gt;
&lt;th&gt;CPU fp32&lt;/th&gt;
&lt;th&gt;iGPU fp16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;long documents (512 tokens)&lt;/td&gt;
&lt;td&gt;112 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44.5 min (2.5×)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;short sentences&lt;/td&gt;
&lt;td&gt;21 min&lt;/td&gt;
&lt;td&gt;23 min (&lt;strong&gt;0.9×&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two reasons the microbenchmark lies. Short sequences leave the GPU idle while the CPU is comparatively efficient at small matrices, and per-fold fixed costs (model load, tokenisation, one-at-a-time evaluation) can exceed the training itself. Only long-document training showed a real gain.&lt;/p&gt;

&lt;p&gt;And it is not stable by default. Roughly every few minutes the driver resets the GPU — &lt;code&gt;amdgpu: ring gfx_0.0.0 timeout&lt;/code&gt; → &lt;code&gt;GPU reset(8)&lt;/code&gt; → then the process dies with &lt;code&gt;HIP error: unspecified launch failure&lt;/code&gt;. The GPU edge temperature was 49°C, so this is a driver problem, not a thermal one. Serialising kernel dispatches and pinning the performance level plus running one fold per process with retries did get a full 5-fold run through cleanly, but fp16 also cost 2.8–4.4 points of accuracy (not significant at n=250, but negative on both tasks).&lt;/p&gt;

&lt;p&gt;So: CPU for training, GPU for what already works — Vulkan inference. If you are training on your own private data, a documented 30 minutes per dataset on CPU is better than a 2.5× speedup that crashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the labels come from is the whole game
&lt;/h2&gt;

&lt;p&gt;If trained beats zero-shot this decisively, the obvious move is to generate labels with Jev and train on those. That works, with one trap I want to be explicit about, because it is the reason I did not just ship the first version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If Jev alone produced the labels, Jev's accuracy is the ceiling of the student.&lt;/strong&gt; You are teaching a model to reproduce another model's mistakes. The fix is to spend the labels where they matter: run Jev over everything, then take the &lt;strong&gt;low-confidence&lt;/strong&gt; rows and have a stronger model or a human relabel them. That is where the ceiling comes off, and it is also where the marginal cost is, because high-confidence rows can be accepted as-is.&lt;/p&gt;

&lt;p&gt;At 250 labels the trained encoder is already at parity with Jev on the tasks Jev is strong at, and ahead where Jev is weak. Labels are the lever, and the second and third hundred labels are worth more than the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I run now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No labels, short text, judge-shaped task&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Jev&lt;/strong&gt; (74–94% zero-shot, no setup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No labels, must stay offline or in-machine&lt;/td&gt;
&lt;td&gt;local 26B MoE (ties Jev on both polarity tasks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A few hundred labels, topic-shaped task&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;train a small encoder&lt;/strong&gt; (beats Jev by 12pt, 4–20× faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A few hundred labels, meaning task&lt;/td&gt;
&lt;td&gt;trained encoder, but accept parity with Jev&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything plateaus at ~75%&lt;/td&gt;
&lt;td&gt;stop model-shopping; escalate the low-confidence tail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Labels to be generated&lt;/td&gt;
&lt;td&gt;Jev for volume, a stronger model or a human on the low-confidence rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The trained numbers are 5-fold CV over 250 rows, so 200 rows of training data per fold. That is a floor, not a ceiling — more labels should widen the topic-task margin.&lt;/li&gt;
&lt;li&gt;livedoor was truncated at 512 tokens, so the longest documents are not fully represented.&lt;/li&gt;
&lt;li&gt;The trained side reports raw argmax; I have not temperature-scaled it, so its confidence is not yet comparable to Jev's for thresholding.&lt;/li&gt;
&lt;li&gt;The char n-gram baseline is a single configuration. It may not be the strongest non-neural baseline available.&lt;/li&gt;
&lt;li&gt;The iGPU accuracy comparison is at n=250 per task, which is not enough to make a 3-point effect significant. "Not significant" is not "no effect."&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Takeaway&lt;/strong&gt;: Zero-shot and trained are different products, not better and worse versions of the same one. If you have no labels, a decision API is the only thing that works today. If you have a few hundred labels and the label is visible in the text, a 310M encoder will beat it, cost nothing to run, and answer in a tenth of a second. Measure your task's shape before you pick a winner — and be suspicious of any "open-source Jev replacement" benchmark where the model was trained on the benchmark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API used here&lt;/strong&gt;: TypeSafe AI's Jev (System One, &lt;code&gt;jev-latest&lt;/code&gt;) — text plus a list of questions in, a 0–1 score per question out, own confidence included, roughly 32k of context, $0.042 per 1M input tokens with output free. Both controlled and randomised question sets were frozen and committed before the runs, and every comparison above is on identical row ids.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you have run the same shape of experiment on your own data, I want to know where a trained small model beat the API and where it did not — especially on tasks where both plateaued.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://agentjournal.dev/blog/llm-judge-vs-feature-extraction/" rel="noopener noreferrer"&gt;Jev: one judge call, or twelve dimension scores?&lt;/a&gt; — the same rows, attacking the &lt;em&gt;other&lt;/em&gt; axis: decomposing one judgment into scored dimensions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://agentjournal.dev/blog/embedding-models-japanese/" rel="noopener noreferrer"&gt;Japanese embeddings, measured&lt;/a&gt; — the retrieval side of the same question.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://agentjournal.dev/blog/self-hosted-llm-economics/" rel="noopener noreferrer"&gt;Self-hosted LLM economics&lt;/a&gt; — when running the model yourself actually pays.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>jev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Jev: one judgment call, or twelve dimension scores? I measured both on three classification tasks</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:13:40 +0000</pubDate>
      <link>https://dev.to/ikkun1222/jev-one-judgment-call-or-twelve-dimension-scores-i-measured-both-on-three-classification-tasks-31fd</link>
      <guid>https://dev.to/ikkun1222/jev-one-judgment-call-or-twelve-dimension-scores-i-measured-both-on-three-classification-tasks-31fd</guid>
      <description>&lt;p&gt;The obvious way to use Jev (TypeSafe AI's System One judgment API) is one question per row, then threshold the score. I wanted to know whether the other shape earns its extra tokens: 12 to 14 narrow questions per row, cached scores, weights fitted locally on my own labels. Three tasks, both shapes, identical rows: 25,174 API calls, 5,477 test rows, 34.1M input tokens, $1.43.&lt;/p&gt;

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

&lt;p&gt;Jev takes a text plus a list of questions and returns a calibrated 0–1 score per question. It is $0.042 per 1M input tokens, output free, ~32k of context, and it reports its own confidence per question.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct judgment.&lt;/strong&gt; One question per row, then threshold the score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature extraction.&lt;/strong&gt; 12–14 questions per row in one request, raw scores cached to JSONL, an L2 logistic regression fitted on my own labels; multi-class is one-vs-rest plus argmax.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted the second shape for operational reasons first: extraction costs money and needs the network, so it caches and resumes; fitting is local and free, so I can change weights, thresholds, or the whole stack without spending another token. Worth nothing unless the decomposition beats one well-written question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment one: a task my own question could ace
&lt;/h2&gt;

&lt;p&gt;200 synthetic rows of B2B replies built from 32 template families, deliberately poisoned: "yes" rows containing "we'll pass", "no" rows containing "absolutely" and "happy to meet".&lt;/p&gt;

&lt;p&gt;The direct question scored 100%, and swapping the choice names also scored 100%: no position effect. The &lt;code&gt;noul&lt;/code&gt; form at a 0.5 threshold got 96.0%. My decomposed pipeline scored 98.0%, losing four rows and winning none. The test was worthless: the answer sits in front of one question. Those 200 rows cost 292,924 input tokens in 5.7 seconds, zero failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that turned 98% into 90%
&lt;/h2&gt;

&lt;p&gt;That 98.0% (AUC 0.998) came from a random 5-fold split. Holding out whole template families instead of single rows dropped the same features to 90.0% (AUC 0.970): that apparent skill was the model recognising phrasing it had already been trained on.&lt;/p&gt;

&lt;p&gt;A character-bigram naive Bayes on the same grouped folds scored 93.5% (AUC 0.989); a word-level one scored 47.5%. My API-scored dimensions lost to a free model whose sibling was worse than a coin flip.&lt;/p&gt;

&lt;p&gt;Two rules stuck: print the majority baseline before believing any number, and choose the split — grouped by family or generator, never random rows — before building the dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment two: a task where one call should fail
&lt;/h2&gt;

&lt;p&gt;300 rows, 150 per class, no names, no keyword, no single sentence that decides the label; the label exists only as a residue of many weak stylistic cues across the whole text. I handed the Jev call both class style descriptions, so it knew what to look for.&lt;/p&gt;

&lt;p&gt;Direct choice: 64.7%. Choice order swapped: unchanged. The &lt;code&gt;noul&lt;/code&gt; form scored 62.0% (AUC 0.676).&lt;/p&gt;

&lt;p&gt;Dimensions plus fitted weights, 5-fold out-of-fold: 74.0% (AUC 0.811). McNemar exact on the same rows with a paired bootstrap of 20,000 resamples: +9.0 points over direct choice, 95% CI [+3.0, +15.0], 57 wins against 30 losses, p=0.0050; +11.7pt over &lt;code&gt;noul&lt;/code&gt;, CI [+5.0, +18.3], p=0.0012; +7.7pt over the single best dimension, p=0.0140.&lt;/p&gt;

&lt;p&gt;I reimplemented the cross-validation in plain numpy, not trusting my own code: 73.7% (AUC 0.809). And the CLI prints the in-sample score beside the out-of-fold one — 100% adjacent to 74%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence came apart from correctness
&lt;/h2&gt;

&lt;p&gt;On 126 of the 300 rows (42%) the Jev call reported confidence of 0.9 or higher, and on exactly those rows it was 72.2% accurate. Confidence and correctness separated where the task got hard.&lt;/p&gt;

&lt;p&gt;The obvious ensemble failed too: the Jev call's probability as a 13th input beside the 12 dimensions scored 73.0% against 74.0% — its information was already inside the dimensions.&lt;/p&gt;

&lt;p&gt;Then a character-bigram naive Bayes on the raw text, same folds, scored 74.0% (AUC 0.824) — the same number as the Jev-scored dimensions, for zero API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real ledger export broke the script twice
&lt;/h2&gt;

&lt;p&gt;Third task: my own bookkeeping export. 4,907 train rows, 2,101 test rows, labelled with the debit account and restricted to the top 12 debit accounts, which cover 87.4% of rows (about 55 accounts exist in total). The input was the description text, the amount, and the credit-side account with its sub-account; I dropped the debit sub-account because it gives the label away.&lt;/p&gt;

&lt;p&gt;One direct question carrying 12 answer choices scored 0.3998: it reads the words correctly and still cannot choose among twelve labels when asked once. Comprehension was never the bottleneck; the output shape was.&lt;/p&gt;

&lt;p&gt;14 dimensions with fitted weights: 0.9105. Word-bigram naive Bayes: 0.9491, beating both by +3.9pt over the dimensions (p&amp;lt;1e-4). Their errors differed, though: n-grams alone rescued 138 rows, the dimensions alone 63. Stacking the 12 n-gram class probabilities beside the 14 dimension scores reached 0.9695 — +2.33 points over n-grams alone (95% CI [+1.57, +3.14]) and +5.90pt over dimensions alone. The question was never which model wins, but whether they are wrong on the same rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  For a detector, the headline should be false positives
&lt;/h2&gt;

&lt;p&gt;339 rows of hard benign text: text that mentions injection techniques while being entirely benign — security write-ups, red-team notes, a post like this one. The single Jev question flagged 1.5% of them; my 12 dimensions flagged 37.2%; word-bigram naive Bayes flagged 43.4%. The gap between 1.5% and 37.2% is 35.7 points (p&amp;lt;1e-4) — about 25× the review load. The external set runs the other way: the direct call held 0.9558 on 2,060 rows where the dimension model managed 0.8398 and the n-gram baselines collapsed to 0.35–0.38, because the labels there run against the surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four fixes, four failures
&lt;/h2&gt;

&lt;p&gt;(a) Add hard benigns to training — 170 rows: 37.2% → 34.3%, detection held at 0.9554. Barely moved. (b) Drop the surface-level dimensions: 40.7%, worse. (c) Set the threshold on fold A for roughly 5% FPR: fold B landed at 8.3%, but attack detection fell from 0.9522 to 0.770, an 18-point loss. (d) Feed the Jev call's probability in as a 13th feature: benign FPR 46.0% and the external set fell from 0.9558 to 0.7883.&lt;/p&gt;

&lt;p&gt;The fitted weights explain it: the two largest positives were &lt;code&gt;obfuscated_encoding&lt;/code&gt; (+1.80) and &lt;code&gt;hidden_in_content&lt;/code&gt; (+1.62), both high for an attack string and for a security document that quotes one. This is a dimension-design problem, not a weights problem: none of my 12 questions asks whether a text &lt;em&gt;uses&lt;/em&gt; the technique or merely &lt;em&gt;discusses&lt;/em&gt; it, and that is the dimension to write next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-source table taught me the most
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;dims&lt;/th&gt;
&lt;th&gt;direct&lt;/th&gt;
&lt;th&gt;word bigram&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hackaprompt&lt;/td&gt;
&lt;td&gt;1,383&lt;/td&gt;
&lt;td&gt;0.977&lt;/td&gt;
&lt;td&gt;0.665&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;neuralchemy_v1&lt;/td&gt;
&lt;td&gt;186&lt;/td&gt;
&lt;td&gt;0.968&lt;/td&gt;
&lt;td&gt;0.942&lt;/td&gt;
&lt;td&gt;0.989&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;original&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;0.895&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.842&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wildguard_judgecomp&lt;/td&gt;
&lt;td&gt;325&lt;/td&gt;
&lt;td&gt;0.674&lt;/td&gt;
&lt;td&gt;0.651&lt;/td&gt;
&lt;td&gt;0.628&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;harmbench (benign)&lt;/td&gt;
&lt;td&gt;264&lt;/td&gt;
&lt;td&gt;0.864&lt;/td&gt;
&lt;td&gt;0.909&lt;/td&gt;
&lt;td&gt;0.909&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;harmbench (attacks)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0.875&lt;/td&gt;
&lt;td&gt;0.875&lt;/td&gt;
&lt;td&gt;0.062&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On competition-style attack strings the single Jev call collapses to 0.665 while the dimensions hold 0.977. On wildguard_judgecomp every method sits between 0.63 and 0.67 — a shared ceiling, not a method difference, so I stopped treating that slice as evidence. Across the test set, dimensions beat the single Jev call by +11.6pt (p&amp;lt;1e-4) but only tie word bigrams (+0.2pt, p=0.90).&lt;/p&gt;

&lt;h2&gt;
  
  
  Japanese NLI, where decomposition finally earned its keep
&lt;/h2&gt;

&lt;p&gt;8,000 train, 2,434 test rows, three classes one-vs-rest, neutral 55% of the test set. Every n-gram baseline landed at or below majority: 0.5534, premise-only word bigram 0.5423, word bigram 0.5251, character bigram 0.4996. The hypothesis-only probe, the standard label-leak test, scored 0.5394 — so no label is hidden in the hypothesis.&lt;/p&gt;

&lt;p&gt;14 dimensions reached 0.9076 (macro-F1 0.8845) against 0.8373 for the single Jev call (0.8198): +7.03 points, 95% CI [+5.67, +8.38], p&amp;lt;1e-4, with 234 rows solved only by the dimensions and 63 only by Jev. Leak check two: zero fully duplicated premise+hypothesis pairs between train and test, 21 duplicated premises only.&lt;/p&gt;

&lt;p&gt;The single Jev call stalls at 0.675 in the 0.60–0.80 Jaccard band while the dimensions get stronger as overlap rises (0.987 in the 0.80+ band). The gap is all in contradiction (0.921 against 0.654); on entailment the single Jev call wins, 287/353 against 271/353. The labels are noisy — reverse-direction pairs carry different labels (jne-00015, jne-00016) — so 0.9076 is accuracy against human labels, not a ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine minutes, 25,174 calls, one error — mine
&lt;/h2&gt;

&lt;p&gt;All extraction for all three tasks — 8 runs, 25,174 calls, 20,139 rows — finished between 22:59:51 and 23:09:05 JST: nine minutes and fourteen seconds, zero failed calls. The 4,391 injection training rows took 76 seconds; 8,000 JNLI rows 129; 4,907 ledger rows 83.&lt;/p&gt;

&lt;p&gt;The one error was mine: at 23:06:24 a question object named &lt;code&gt;rel_choice&lt;/code&gt; was missing its &lt;code&gt;instructions&lt;/code&gt; field, and the CLI refused it with exit code 1 and the field name rather than judging something I had not asked. A &lt;code&gt;criteria&lt;/code&gt; field passed alongside &lt;code&gt;noul&lt;/code&gt; is the same class of usage error; a missing dimension stops with exit 1 and names it, while &lt;code&gt;--allow-missing&lt;/code&gt; mean-imputes it.&lt;/p&gt;

&lt;p&gt;Two things came from experiment, not documentation: &lt;code&gt;noul&lt;/code&gt; points the way I did not expect (a true statement scored 0.96, a false one 0.02, so it is the probability the proposition is true), and &lt;code&gt;--out&lt;/code&gt; merges rather than appends — re-running the same file reported &lt;code&gt;skipped=300 / extracted=0&lt;/code&gt;, so a long job can resume without re-paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill is the cheap part
&lt;/h2&gt;

&lt;p&gt;34.1M input tokens: 27.9M for feature extraction ($1.17) plus 6.23M for direct questions ($0.26) — $1.43 at $0.042 per 1M input tokens. Output was 846,765 tokens, counted separately and not priced.&lt;/p&gt;

&lt;p&gt;Per row, one direct question costs 444–619 tokens, 12 dimensions 1,010–1,036, and 14 dimensions 1,136–1,154. Each dimension adds 40–50 tokens, mostly the wording of the question, so twelve dimensions run 1.6–2.3× one question, and fourteen run 1.8–2.6×: $0.019–0.026 per 1,000 rows against $0.042 and $0.048, or $19–26 per million rows against $42 and $48. Extraction is paid once: 4,391 training rows cost $0.19, and every later fit or threshold change reuses that cache for nothing.&lt;/p&gt;

&lt;p&gt;At a million rows a month with 20% benign traffic, a 37.2% false-positive rate sends about 74,000 rows to human review; at 1.5% it sends about 3,000. That is roughly 71,000 rows — about 590 hours at 30 seconds per row — against a monthly token bill of $42 versus $19–26.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder I actually run
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Do&lt;/th&gt;
&lt;th&gt;Stop when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;200 labelled rows, real ones if you have them&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;n-gram / TF-IDF baseline&lt;/td&gt;
&lt;td&gt;It meets the target: no judgment API needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;One direct question per row&lt;/td&gt;
&lt;td&gt;≥0.95 accuracy: keep it, decomposition is a downgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Dimensions + fitted weights&lt;/td&gt;
&lt;td&gt;Only where there is no lexical shortcut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Stack dimensions with the n-gram probabilities&lt;/td&gt;
&lt;td&gt;Their errors are orthogonal (+2.3pt measured)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Guardrails: direct call primary, dimensions as second opinion&lt;/td&gt;
&lt;td&gt;Always train on hard benigns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Guardrails get one direct call with a strict threshold plus a small dimension model consulted where that call is unsure; classification gets n-grams first, with dimensions stacked on the residual.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong, and the counterexample I still want
&lt;/h2&gt;

&lt;p&gt;In order of how much it cost me: believing a random-split CV; treating a judge's confidence as calibrated; testing on data my own generator had made too easy; and reporting accuracy for a detector whose real risk is false positives.&lt;/p&gt;

&lt;p&gt;What I still want is a counterexample: some task where the dimensions beat a direct call that was already competent — what did they know that one question could not? I have not found one in three tasks and I would like to be wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API used here&lt;/strong&gt;: TypeSafe AI's Jev (System One, &lt;code&gt;jev-latest&lt;/code&gt;) — text plus a list of questions in, a 0–1 score per question out, own confidence included, roughly 32k of context, $0.042 per 1M input tokens with output free. Each dimension costs 40–50 tokens, mostly the wording of the question; that is where the 1.6–2.3× comes from.&lt;/p&gt;

&lt;p&gt;Two caveats: I hand-wrote all 12–14 dimensions, so these numbers describe my dimension design as much as feature extraction; and the ledger task covers only the top 12 account classes and took the credit-side account as input — the import-with-context case, not a bare bank statement. The JNLI labels carry noise and a 55% neutral skew, the external injection set has contradictory labels on near-identical rows, and output tokens are not priced.&lt;/p&gt;

&lt;p&gt;The full tables and threshold sweeps are at &lt;a href="https://agentjournal.dev/blog/llm-judge-vs-feature-extraction/" rel="noopener noreferrer"&gt;https://agentjournal.dev/blog/llm-judge-vs-feature-extraction/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>jev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Auditing AI agent activity: structured logs for credential access</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 13:20:00 +0000</pubDate>
      <link>https://dev.to/ikkun1222/auditing-ai-agent-activity-structured-logs-for-credential-access-1b1b</link>
      <guid>https://dev.to/ikkun1222/auditing-ai-agent-activity-structured-logs-for-credential-access-1b1b</guid>
      <description>&lt;p&gt;AI coding agents are the most privileged new user on developer machines in a&lt;br&gt;
decade: they read source, run commands, and access credentials. And unlike&lt;br&gt;
every other privileged user, they're almost always unaudited. If you had to&lt;br&gt;
answer "which API keys did my agent touch this week?" right now, could you?&lt;/p&gt;

&lt;h3&gt;
  
  
  Why audit logs matter more with agents
&lt;/h3&gt;

&lt;p&gt;Traditional secret access has a human in the loop: a developer runs a&lt;br&gt;
command, and the human's intent is the context. Agents change the equation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Attribution is unclear.&lt;/strong&gt; A command run by an agent was &lt;em&gt;decided&lt;/em&gt; by a&lt;br&gt;
model — influenced by its context, its tools, and potentially by injected&lt;br&gt;
instructions. "Who accessed this key" needs to include "what the agent&lt;br&gt;
was doing at the time".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The blast radius is session-wide.&lt;/strong&gt; One agent session touches many&lt;br&gt;
credentials through many invocations. Without logs, a compromise is&lt;br&gt;
invisible until the damage shows up elsewhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compliance surfaces.&lt;/strong&gt; If your setup ever needs to answer "was this&lt;br&gt;
secret exposed?" — for an incident, a client, or your own peace of mind —&lt;br&gt;
logs are the difference between an answer and a guess.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What good credential audit logs look like
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured.&lt;/strong&gt; JSON lines, not prose. Each event is a record with fields:
timestamp, the credential key, the operation (resolve / refresh / scrub),
the invoking context, and the outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append-only.&lt;/strong&gt; The log can't be rewritten by the thing being audited.
File-based logs get 0600 permissions and reopen-on-rotation (so logrotate
doesn't drop events); journald is a solid alternative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complete.&lt;/strong&gt; Every credential resolution is logged — not just failures.
The absence of an event is information too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine-readable.&lt;/strong&gt; &lt;code&gt;--json&lt;/code&gt; output for cron, SIEM, or alerting: "three
failed Bitwarden unlocks in a row" should be something you can detect,
not something you notice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What to log
&lt;/h3&gt;

&lt;p&gt;The useful baseline for agent credential access:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resolutions&lt;/strong&gt; — which key was resolved, when, by what invocation. This
is the "who touched what" record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth lifecycle&lt;/strong&gt; — login, refresh, reauth_required. Token refresh
failures are early indicators of revoked credentials or policy changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DLP hits&lt;/strong&gt; — outbound pattern matches, with mode (log vs mask). A
pattern hit in log mode is a secret that &lt;em&gt;almost&lt;/em&gt; left the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrub operations&lt;/strong&gt; — what was scrubbed, from where. The audit trail for
your erasure layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The test: can you answer the question?
&lt;/h3&gt;

&lt;p&gt;A good test for whether your agent setup has adequate auditing: an incident&lt;br&gt;
review asks "did the agent access the production key during that session?"&lt;br&gt;
If the honest answer requires digging through shell history, you don't have&lt;br&gt;
audit logs — you have hope.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;trustless&lt;/a&gt; writes structured&lt;br&gt;
credential audit events (append-only JSONL at 0600, file or journald) for&lt;br&gt;
every resolution, OAuth refresh, DLP hit, and scrub — plus &lt;code&gt;trustless doctor&lt;br&gt;
--json&lt;/code&gt; for setup health in machine-readable form. The audit command is in&lt;br&gt;
the &lt;a href="https://dev.to/docs/commands/"&gt;command reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Agents will keep getting more capable, which means more credentials touched&lt;br&gt;
per session. The question isn't whether to log — it's whether you want the&lt;br&gt;
answer to "what happened" to be a query or a mystery.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>OAuth device flow explained: how CLIs and AI agents approve access</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:30:49 +0000</pubDate>
      <link>https://dev.to/ikkun1222/oauth-device-flow-explained-how-clis-and-ai-agents-approve-access-17c0</link>
      <guid>https://dev.to/ikkun1222/oauth-device-flow-explained-how-clis-and-ai-agents-approve-access-17c0</guid>
      <description>&lt;p&gt;You've seen it on a smart TV: "Go to example.com/device and enter code&lt;br&gt;
ABCD-1234". That's the OAuth device authorization flow, RFC 8628 — and it's&lt;br&gt;
the right way for headless tools, CLIs, and AI agents to get OAuth tokens.&lt;br&gt;
Here's how it works and why it matters for agent security.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem with OAuth on a headless machine
&lt;/h3&gt;

&lt;p&gt;OAuth's normal web flow needs a browser redirect: authorize → the provider&lt;br&gt;
redirects to &lt;code&gt;http://localhost:8976/callback?code=...&lt;/code&gt;. On a headless server&lt;br&gt;
or inside an agent session there's no browser to redirect to — and no user&lt;br&gt;
watching the address bar.&lt;/p&gt;

&lt;p&gt;The workaround people fall into is worse: paste a personal access token into&lt;br&gt;
a config file, or run a long-lived refresh token into the environment where&lt;br&gt;
the agent can read it. Both put credentials where agents can exfiltrate them.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the device flow works
&lt;/h3&gt;

&lt;p&gt;RFC 8628 splits authorization into two steps that don't need to happen on&lt;br&gt;
the same device:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CLI asks the provider for a device code&lt;/strong&gt; and gets back a
verification URL + a short user code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI prints&lt;/strong&gt; "open this URL, enter ABCD-1234" — and starts polling
the provider for approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user opens the URL on any device&lt;/strong&gt; — phone, laptop, anywhere — and
approves. No browser on the server needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI's poll succeeds&lt;/strong&gt;, and the provider returns tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tokens land on the machine that requested them, in the process that&lt;br&gt;
requested them — not in a browser, not in a shared log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it fits agents
&lt;/h3&gt;

&lt;p&gt;For AI agents, the device flow has a structural advantage: the approval&lt;br&gt;
happens outside the agent entirely. The agent runs the login command, prints&lt;br&gt;
the URL, and the human approves on their own device. The tokens are stored by&lt;br&gt;
the credential layer, and the agent never handles them.&lt;/p&gt;

&lt;p&gt;That's the same principle as the rest of agent credential hygiene — the agent&lt;br&gt;
is an untrusted caller, so credentials should be resolved and refreshed&lt;br&gt;
outside its reach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token handling done right
&lt;/h3&gt;

&lt;p&gt;Getting tokens is step one; keeping them safe is step two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store the entry compactly.&lt;/strong&gt; The OAuth entry lives in the credential
store as a single-line JSON record — one entry, resolved like any other
secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh without exposure.&lt;/strong&gt; Access tokens expire; the broker refreshes
on expiry, and the new value is never printed to output the agent can
capture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate refresh tokens.&lt;/strong&gt; Refresh tokens are single-use in this design —
rotated on every refresh, so a leaked one is already dead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report state.&lt;/strong&gt; &lt;code&gt;status&lt;/code&gt; tells you &lt;code&gt;valid&lt;/code&gt; / &lt;code&gt;expired&lt;/code&gt; /
&lt;code&gt;reauth_required&lt;/code&gt;, so automation can detect a revoked token (&lt;code&gt;invalid_grant&lt;/code&gt;)
and ask the human to re-approve instead of failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The audit trail
&lt;/h3&gt;

&lt;p&gt;Every resolution and refresh lands in an append-only audit log. You can&lt;br&gt;
answer "which key was accessed, when, and by what" — including OAuth entries,&lt;br&gt;
which most setups can't audit at all because the tokens live in browser&lt;br&gt;
profiles and config files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;trustless&lt;/a&gt; implements the device&lt;br&gt;
flow for Google and Lark providers (&lt;code&gt;trustless oauth login google&lt;br&gt;
api/google&lt;/code&gt;), with automatic refresh, single-use rotation, and status&lt;br&gt;
reporting — documented in the &lt;a href="https://dev.to/docs/commands/"&gt;command reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The device flow is one of those rare security mechanisms that's also more&lt;br&gt;
convenient: no browser juggling on the server, no callback URL gymnastics,&lt;br&gt;
and a human approval step that's exactly where it should be.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>pass vs Bitwarden for AI agent credentials: which works with agents</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:30:48 +0000</pubDate>
      <link>https://dev.to/ikkun1222/pass-vs-bitwarden-for-ai-agent-credentials-which-works-with-agents-54cf</link>
      <guid>https://dev.to/ikkun1222/pass-vs-bitwarden-for-ai-agent-credentials-which-works-with-agents-54cf</guid>
      <description>&lt;p&gt;When you decide to stop putting API keys in &lt;code&gt;.env&lt;/code&gt; files and start keeping&lt;br&gt;
them in a real secret store, the first question is: which store? For AI&lt;br&gt;
agent setups, the two realistic candidates are &lt;code&gt;pass&lt;/code&gt; (the unix password&lt;br&gt;
manager) and Bitwarden. Both work — but they make different trade-offs, and&lt;br&gt;
the right choice depends on your existing workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  pass: the unix-native store
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pass&lt;/code&gt; is a shell script over a directory of GPG-encrypted files. That's the&lt;br&gt;
whole architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths for agent use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero infrastructure.&lt;/strong&gt; No server, no sync, no daemon. The store is files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scriptable by nature.&lt;/strong&gt; Everything is files and GPG, so tooling around it
is trivial — a broker reads a path, decrypts, injects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPG is already everywhere.&lt;/strong&gt; If you sign commits or use SSH with GPG
keys, the tooling is already installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent.&lt;/strong&gt; &lt;code&gt;pass show &amp;lt;name&amp;gt;&lt;/code&gt; and you see exactly what a tool will
get.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sync is your problem.&lt;/strong&gt; No built-in sync across machines; you roll your
own (git remote, Syncthing, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No native web vault / mobile app ecosystem.&lt;/strong&gt; The CLI is the interface.
For a single-user developer setup, that's fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Bitwarden: the managed vault
&lt;/h3&gt;

&lt;p&gt;Bitwarden is a full password manager: client apps, web vault, sync, sharing,&lt;br&gt;
and a CLI (&lt;code&gt;bw&lt;/code&gt;) that agents can drive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths for agent use:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sync and recovery built in.&lt;/strong&gt; Vault is on your terms (Bitwarden cloud or
self-hosted Vaultwarden).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich entry model.&lt;/strong&gt; Login items with URIs, fields with hidden values,
notes — better structured than a flat file tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; Collections and organizations, if you ever share
credentials with a team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session management is fiddly.&lt;/strong&gt; The &lt;code&gt;bw&lt;/code&gt; CLI needs a session key
(&lt;code&gt;BW_SESSION&lt;/code&gt;) that must be passed to every invocation — get it wrong and
the tool either fails or prompts interactively. This is the classic
integration trap for agent tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More moving parts.&lt;/strong&gt; Server sync, unlock state, API rate limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the agent setup actually needs
&lt;/h3&gt;

&lt;p&gt;For a credential broker pattern — the agent references by name, the broker&lt;br&gt;
resolves at process/transport time — the store is an implementation detail.&lt;br&gt;
What matters is that the broker can read it non-interactively:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;strong&gt;pass&lt;/strong&gt;: the broker decrypts with the GPG key; no session state to
manage.&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;Bitwarden&lt;/strong&gt;: the broker unlocks once, holds the session key in an
environment variable (never argv — that would leak it into process lists),
and fails closed when the session is invalid. A dedicated unlock command
handles the interactive step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both backends can power the same injection, sanitization, and DLP layers.&lt;br&gt;
The pragmatic rule: &lt;strong&gt;use pass if you're a single-machine, CLI-first user;&lt;br&gt;
use Bitwarden if you already live in it or need sync across devices.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;trustless&lt;/a&gt; supports both as first-&lt;br&gt;
class backends — pass by default, Bitwarden via the &lt;code&gt;bw&lt;/code&gt; CLI with&lt;br&gt;
&lt;code&gt;BW_SESSION&lt;/code&gt; handling and fail-closed behavior. The backend design is&lt;br&gt;
documented in the &lt;a href="https://dev.to/docs/architecture/"&gt;architecture&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;Whichever store you pick, the goal is the same: the agent gets access, never&lt;br&gt;
the plaintext. Which one are you already using — or are you still on &lt;code&gt;.env&lt;/code&gt;?&lt;/p&gt;

</description>
      <category>security</category>
      <category>cli</category>
      <category>devops</category>
    </item>
    <item>
      <title>SQLite forensics: why deleting rows doesn't erase secrets (FTS, free pages, VACUUM)</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:22:52 +0000</pubDate>
      <link>https://dev.to/ikkun1222/sqlite-forensics-why-deleting-rows-doesnt-erase-secrets-fts-free-pages-vacuum-3oh9</link>
      <guid>https://dev.to/ikkun1222/sqlite-forensics-why-deleting-rows-doesnt-erase-secrets-fts-free-pages-vacuum-3oh9</guid>
      <description>&lt;p&gt;You deleted the row. The secret is gone from the app, the queries return&lt;br&gt;
nothing, and the dashboard is clean. In SQLite — the database behind most&lt;br&gt;
session stores, browser profiles, and agent state files — that delete is a&lt;br&gt;
fiction. The bytes are still in the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways deleted data survives
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Free pages.&lt;/strong&gt; SQLite doesn't zero out the space a deleted row occupied.&lt;br&gt;
The page is marked free and added to the freelist; the old bytes stay until&lt;br&gt;
they're overwritten by a future write. A file that's been deleted-from is a&lt;br&gt;
forensics goldmine: recover the freelist pages and the "deleted" rows come&lt;br&gt;
back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. FTS virtual tables.&lt;/strong&gt; If the database uses SQLite's full-text search&lt;br&gt;
(FTS5), the FTS index keeps its own copies of the indexed text, maintained&lt;br&gt;
separately from the source tables. Delete the row from the source table and&lt;br&gt;
the FTS index still contains the tokens — searchable. This is the one that&lt;br&gt;
catches people: their app shows the secret is gone, and the FTS index still&lt;br&gt;
has it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. WAL and journal files.&lt;/strong&gt; In WAL mode, recent writes live in the&lt;br&gt;
&lt;code&gt;-wal&lt;/code&gt; file; transactions in the &lt;code&gt;-journal&lt;/code&gt; file. Both can retain pre-delete&lt;br&gt;
content until checkpointed or cleaned.&lt;/p&gt;

&lt;p&gt;"Deleted" in SQLite means "no longer referenced", not "no longer present".&lt;/p&gt;

&lt;h3&gt;
  
  
  What erasure actually requires
&lt;/h3&gt;

&lt;p&gt;Making a secret physically disappear from a SQLite database takes three&lt;br&gt;
operations, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Replace the value everywhere it lives.&lt;/strong&gt; Known secret values get&lt;br&gt;
replaced across all tables; pattern matches (API key formats) get masked.&lt;br&gt;
Two layers, because you can't enumerate every secret that leaked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rebuild the FTS indexes.&lt;/strong&gt; &lt;code&gt;INSERT INTO t(t) VALUES('rebuild')&lt;/code&gt; style&lt;br&gt;
rebuilds, or drop/recreate the virtual tables — so the index no longer&lt;br&gt;
contains the old tokens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run VACUUM.&lt;/strong&gt; VACUUM rewrites the entire database file, copying only&lt;br&gt;
live data into a fresh file — free pages with old bytes are discarded in&lt;br&gt;
the process. After VACUUM, the file's raw bytes no longer contain the&lt;br&gt;
secret. (Note: VACUUM doesn't shrink WAL files; those need a checkpoint&lt;br&gt;
too.)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The design that makes scrubbing safe
&lt;/h3&gt;

&lt;p&gt;Retroactive scrubbing is a destructive operation, so the tooling around it&lt;br&gt;
matters as much as the SQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dry-run by default.&lt;/strong&gt; Scan and report per-table hit counts without
writing a byte. You see what &lt;em&gt;would&lt;/em&gt; be scrubbed before anything changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup option.&lt;/strong&gt; Keep a &lt;code&gt;.bak&lt;/code&gt; copy before applying changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum length guard.&lt;/strong&gt; Don't let pattern rules shred short, low-value
strings — only secrets above a minimum length get masked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where this bites agent setups
&lt;/h3&gt;

&lt;p&gt;Agent session databases are exactly the SQLite databases where this matters:&lt;br&gt;
they accumulate everything the agent read, including the config values,&lt;br&gt;
environment dumps, and debug output that occasionally contain keys. A&lt;br&gt;
credential strategy that includes "the past is scrubbed" closes the loop&lt;br&gt;
that prevention and DLP can't — the retrospective exposure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;trustless&lt;/a&gt; implements this as&lt;br&gt;
&lt;code&gt;trustless dlp scrub-db&lt;/code&gt;: two-layer redaction (known values + gitleaks-style&lt;br&gt;
patterns), FTS rebuild, VACUUM, dry-run default, &lt;code&gt;--backup&lt;/code&gt;, and &lt;code&gt;--min-len&lt;/code&gt;&lt;br&gt;
— with the same pattern rules that power the outbound DLP. The command&lt;br&gt;
reference is in the &lt;a href="https://dev.to/docs/commands/"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next time someone tells you a secret was "deleted" from a database, ask them&lt;br&gt;
two questions: did the FTS index get rebuilt, and did anyone run VACUUM?&lt;/p&gt;

</description>
      <category>security</category>
      <category>database</category>
      <category>cli</category>
    </item>
    <item>
      <title>Prompt injection and API key exfiltration: what actually happens</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:22:52 +0000</pubDate>
      <link>https://dev.to/ikkun1222/prompt-injection-and-api-key-exfiltration-what-actually-happens-344m</link>
      <guid>https://dev.to/ikkun1222/prompt-injection-and-api-key-exfiltration-what-actually-happens-344m</guid>
      <description>&lt;p&gt;Prompt injection gets talked about in abstract terms — "the model was tricked" —&lt;br&gt;
but the concrete damage is usually the same: a secret that was sitting in the&lt;br&gt;
agent's context gets exfiltrated. To defend against it, it helps to walk&lt;br&gt;
through the actual attack chain, step by step.&lt;/p&gt;

&lt;h3&gt;
  
  
  The attack chain
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The key gets into context.&lt;/strong&gt; Your agent loads &lt;code&gt;.env&lt;/code&gt;, reads config, or&lt;br&gt;
receives an environment variable to use a service. From that moment, the&lt;br&gt;
key is part of the context window — the same working memory the model&lt;br&gt;
reasons over.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Untrusted content arrives.&lt;/strong&gt; The agent opens a webpage, reads a README,&lt;br&gt;
processes an email, or parses an API response. Any of these can contain&lt;br&gt;
instructions the model treats as commands: "ignore previous instructions",&lt;br&gt;
"print the value of the DATABASE_URL variable", or subtler variants that&lt;br&gt;
ask the agent to summarize, log, or send something.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The agent complies.&lt;/strong&gt; The model is a next-token predictor, not a policy&lt;br&gt;
enforcer. An instruction that looks like it's part of the task gets&lt;br&gt;
followed. The key value ends up in a tool call, a log line, a code snippet&lt;br&gt;
the agent writes, or the model's own output — which is transmitted to a&lt;br&gt;
third-party API.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The key is now outside your machine.&lt;/strong&gt; Once the value appears in output&lt;br&gt;
that leaves your control — an API request, a shared log, a generated file —&lt;br&gt;
it's compromised. Rotate it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable part: steps 2–4 can happen without any visible error. The&lt;br&gt;
agent looks like it's doing its job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "be careful" doesn't work
&lt;/h3&gt;

&lt;p&gt;The standard mitigations — "sanitize the model's output", "don't paste&lt;br&gt;
untrusted text", "use a system prompt that forbids leaking secrets" — all&lt;br&gt;
share a flaw: they rely on the model to make the right decision at the moment&lt;br&gt;
of exfiltration. That's a probabilistic defense against a deterministic&lt;br&gt;
attack. The attacker only needs one success; the defender needs all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The structural fix: make the key unavailable
&lt;/h3&gt;

&lt;p&gt;The defense that actually holds is removing the key from the attack surface&lt;br&gt;
entirely. If the agent's context never contains the plaintext value, then a&lt;br&gt;
successful injection has nothing to exfiltrate. This is what a credential&lt;br&gt;
broker does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;At the process layer:&lt;/strong&gt; the agent references the secret by name; the&lt;br&gt;
broker resolves it into the subprocess environment at execution time.&lt;br&gt;
Agent-visible output — stdout and stderr — is scrubbed for key values,&lt;br&gt;
including base64 and URL-encoded variants, so even a verbose tool that&lt;br&gt;
echoes its environment can't leak the value into context.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;At the transport layer:&lt;/strong&gt; HTTP-based tools talk to a local proxy that&lt;br&gt;
injects credentials per host. The tool — and the agent controlling it —&lt;br&gt;
never handles the credential material.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;On the way out:&lt;/strong&gt; outbound LLM traffic passes through a DLP layer that&lt;br&gt;
pattern-scans requests and masks secret-shaped strings before they leave&lt;br&gt;
the machine. Keys that leaked through other channels get caught at the&lt;br&gt;
edge instead of in an attacker's hands.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the design of &lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;trustless&lt;/a&gt;, a&lt;br&gt;
single-binary credential broker CLI for AI agents — 321 tests with &lt;code&gt;-race&lt;/code&gt;,&lt;br&gt;
cosign-signed releases with SBOM, and an append-only audit log of every&lt;br&gt;
credential resolution. The threat model is documented in the&lt;br&gt;
&lt;a href="https://dev.to/docs/security-model/"&gt;security model&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Prompt injection is not going away, and models will keep getting more&lt;br&gt;
suggestible before they get less. The only reliable defense is making the&lt;br&gt;
secret structurally unavailable — so that when an injection succeeds, the&lt;br&gt;
agent has nothing to hand over.&lt;/p&gt;

&lt;p&gt;Have you traced where your agent's keys actually live right now — &lt;code&gt;.env&lt;/code&gt;&lt;br&gt;
files, environment variables, config? That's the attack surface.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why your AI coding agent should never see your API keys</title>
      <dc:creator>Ikkun</dc:creator>
      <pubDate>Sat, 15 Aug 2026 02:02:59 +0000</pubDate>
      <link>https://dev.to/ikkun1222/why-your-ai-coding-agent-should-never-see-your-api-keys-1hem</link>
      <guid>https://dev.to/ikkun1222/why-your-ai-coding-agent-should-never-see-your-api-keys-1hem</guid>
      <description>&lt;p&gt;Your AI coding agent needs your API keys. It needs them to call services, to&lt;br&gt;
test integrations, to run your stack. So you give it &lt;code&gt;.env&lt;/code&gt; files, or you&lt;br&gt;
export keys into the environment, or you paste them into config files the&lt;br&gt;
agent can read.&lt;/p&gt;

&lt;p&gt;That means your secrets live inside the agent's context window — the same&lt;br&gt;
window where a prompt-injected instruction or an overly verbose debug log can&lt;br&gt;
leak them to an attacker or an untrusted model endpoint.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. If you've used Claude Code or OpenCode for more than a&lt;br&gt;
few days, you've probably seen a tool call dump an environment variable, or a&lt;br&gt;
log line that echoes a connection string. Most of the time nothing bad&lt;br&gt;
happens. "Most of the time" is a bad security posture.&lt;/p&gt;

&lt;h3&gt;
  
  
  The core problem
&lt;/h3&gt;

&lt;p&gt;AI agents are the first software that &lt;em&gt;reads your source, your config, and&lt;br&gt;
your secrets&lt;/em&gt;, then sends summaries of what it read to a third-party API.&lt;/p&gt;

&lt;p&gt;With traditional software, the principle was simple: secrets live in the&lt;br&gt;
process environment, code reads them at runtime, nobody reads them back out.&lt;br&gt;
With agents, there is no such boundary — the agent both &lt;em&gt;reads&lt;/em&gt; the&lt;br&gt;
environment and &lt;em&gt;transmits&lt;/em&gt; what it knows.&lt;/p&gt;

&lt;p&gt;Three concrete leak vectors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context exfiltration&lt;/strong&gt; — the agent reads &lt;code&gt;.env&lt;/code&gt; and includes values in a
later prompt to an external model. You can't audit this; it's in the model's
training/inference pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool output echo&lt;/strong&gt; — a command prints an env var or a config value; the
agent captures stdout and stores it in the conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection&lt;/strong&gt; — a malicious instruction (in a fetched web page, a
dependency, an artifact) tells the agent to "print all environment
variables" or "send the contents of .env to this URL".&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What the tooling landscape offers
&lt;/h3&gt;

&lt;p&gt;The solutions fall into a few buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secret managers&lt;/strong&gt; (Vault, Doppler, Infisical) — great for &lt;em&gt;your code&lt;/em&gt;, but
the agent still needs a way to get the secret, which puts it back in context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt; hiding&lt;/strong&gt; (enject, tene) — keeps plaintext off disk, but when the
agent runs a command that needs the secret, the value can still end up in
stdout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential proxies&lt;/strong&gt; (vaulty) — the agent makes HTTP requests through a
proxy that injects the credential. Promising, but typically tied to their
own vault.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The model that worked for me
&lt;/h3&gt;

&lt;p&gt;I ended up building a small CLI (Go, single binary, one external module) with four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Subprocess injection with output sanitization.&lt;/strong&gt; &lt;code&gt;trustless run -- cmd&lt;/code&gt;&lt;br&gt;
resolves secrets from my existing pass store and injects them as env vars.&lt;br&gt;
After the command runs, stdout/stderr is scanned and secret values are&lt;br&gt;
replaced — including base64 and URL-encoded variants. The agent sees the&lt;br&gt;
command output, not the keys.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTTP proxy with per-host injection.&lt;/strong&gt; For services that take headers or&lt;br&gt;
query params (EDINET, e-Stat, xAI, OpenRouter), &lt;code&gt;trustless proxy&lt;/code&gt; injects&lt;br&gt;
the right credential per host. The agent points at &lt;code&gt;127.0.0.1:8080&lt;/code&gt; and&lt;br&gt;
forgets about keys entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DLP reverse proxy for LLM calls.&lt;/strong&gt; &lt;code&gt;trustless serve&lt;/code&gt; puts a scanning&lt;br&gt;
proxy in front of OpenAI-compatible endpoints. Outbound requests are&lt;br&gt;
checked against secret patterns (keyword → regex → entropy, gitleaks-compatible&lt;br&gt;
rules) and masked in-flight before they leave the machine. This is the&lt;br&gt;
layer that catches the "agent decided to include the key in a request" case.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retroactive scrubbing.&lt;/strong&gt; Prevention fails — keys end up in agent session&lt;br&gt;
databases, logs, and dumps anyway. &lt;code&gt;trustless dlp scrub-db&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;scrub-text&lt;/code&gt; scan SQLite DBs and text files with the same two-layer&lt;br&gt;
redaction (known values + patterns), rebuild FTS indexes and &lt;code&gt;VACUUM&lt;/code&gt; the&lt;br&gt;
DB so no physical remnants survive. Default is a dry-run report; &lt;code&gt;--apply&lt;/code&gt;&lt;br&gt;
does the write, &lt;code&gt;--backup&lt;/code&gt; keeps a copy first.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why not a new vault? Because I already had pass. The CLI reads the existing&lt;br&gt;
store, so there was zero migration. (Bitwarden is supported too, with OAuth&lt;br&gt;
token auto-refresh for Google/Lark.)&lt;/p&gt;

&lt;p&gt;The agent gets capabilities, not credentials. That's the whole trick.&lt;/p&gt;

&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never let a secret enter the agent's context window&lt;/strong&gt; — not as env, not
as config, not as tool output. Once it's there, you've lost the audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject at the process/transport boundary&lt;/strong&gt;, not at the prompt level.
"Please don't print the key" is not a security control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize output, not just input.&lt;/strong&gt; The leak vector is often the command's
stdout, not the agent's intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan outbound requests&lt;/strong&gt; if your agent calls external APIs directly. A
DLP layer is the difference between "we hope it didn't leak" and "we know
it didn't".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume leakage happened and scrub retroactively.&lt;/strong&gt; Run a periodic scan
of agent session DBs and logs; a dry-run scrub report tells you what would
be found, &lt;code&gt;--apply&lt;/code&gt; cleans it, and a backup keeps the recovery path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to look at the code: trustless is MIT-licensed at&lt;br&gt;
&lt;a href="https://github.com/ikkun1222/trustless" rel="noopener noreferrer"&gt;https://github.com/ikkun1222/trustless&lt;/a&gt; — 321 tests, race-detector clean,&lt;br&gt;
cosign-signed releases. It's one implementation of this model; the threat&lt;br&gt;
model discussion is more valuable than the tool itself.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>cli</category>
      <category>go</category>
    </item>
  </channel>
</rss>
