<?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: Konstantin Gredeskoul</title>
    <description>The latest articles on DEV Community by Konstantin Gredeskoul (@kigster).</description>
    <link>https://dev.to/kigster</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%2F662581%2F33b9dfad-3922-4dfd-ab2b-23085bd4b803.jpeg</url>
      <title>DEV Community: Konstantin Gredeskoul</title>
      <link>https://dev.to/kigster</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kigster"/>
    <language>en</language>
    <item>
      <title>Home-made Evals Just Taste Better</title>
      <dc:creator>Konstantin Gredeskoul</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:14:28 +0000</pubDate>
      <link>https://dev.to/kigster/home-made-evals-just-taste-better-2d5k</link>
      <guid>https://dev.to/kigster/home-made-evals-just-taste-better-2d5k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Note&lt;/p&gt;


&lt;p&gt;This is a shortened version of the original writing on &lt;a href="https://kig.re/2026/11/02/llm-evals-is-a-ruby-gem-for-testing-probabilistic-assumptions-in-your-AI-datasets.html" rel="noopener noreferrer"&gt;kig.re&lt;/a&gt; — my personal blog.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is what a real lead typed into a tax-preparation intake form:&lt;/p&gt;

&lt;p&gt;“Got married in June, haven’t figured out how we’re filing yet. There’s an IRS letter somewhere in a kitchen drawer I haven’t opened. I also deliver groceries on weekends.”&lt;/p&gt;

&lt;p&gt;Only one fact in that paragraph is clear: the weekend side gig. A model read it anyway and set &lt;strong&gt;filing_status&lt;/strong&gt; to &lt;strong&gt;married_jointly&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Helpful. Wrong. The form then skipped the filing-status question and treated a guess as settled. That is the failure I care about most, and it is invisible if you only eyeball a few outputs.&lt;/p&gt;

&lt;p&gt;The vague tax-preparation case: the lead's input, the expected JSON with filing_status null, gpt-4o-mini hallucinating married_jointly at 78.8%, and deepseek-v4.1-flash-1017 scoring 100% with empty diagnostics.&lt;/p&gt;

&lt;p&gt;I built llm-evals so I would stop writing scripts/eval.rb for the fourth time. It is a Ruby CLI for multi-dataset, multi-case evaluation suites. Compare models, efforts, temperatures. Rank them by one weighted score that balances accuracy, hallucinations, errors, latency, and cost. On RubyGems as of writing: 0.5.0. Two commands: evals and llm-evals.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;llm-evals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Home-made pasta tastes better because you chose the flour. Home-made evals taste better because you chose the weights, the cases, and the failure modes that matter to your product. Store-bought leaderboards cannot taste your soup.&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%2Fgimhon6ecosgprqlilf0.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%2Fgimhon6ecosgprqlilf0.png" alt="leaderboard" width="800" height="831"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Evals are the unit tests for probabilistic software
&lt;/h2&gt;

&lt;p&gt;A unit test asks: given this input, does my code return exactly this output? An eval asks a softer question: given representative inputs, how often and how well does the model produce what I expect? You cannot assert equality against a probability distribution. You can score it. Score the same cases every time, and you can finally measure what a prompt tweak, a temperature change, or a model swap actually did.&lt;/p&gt;

&lt;p&gt;The scorer decides what “good” means. The eval makes the comparison honest and repeatable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem that started it: Qualified.At
&lt;/h2&gt;

&lt;p&gt;The gem grew out of Qualified.At, which I wrote about when it launched. A business builds an intake form, embeds it, and visitors answer questions before anyone picks up the phone. The forms use the Inquirex gems. Many start with a free-text box: tell us about your situation. An LLM reads that paragraph and extracts structured answers, so a 20- or 30-question form can shrink to a few follow-ups.&lt;/p&gt;

&lt;p&gt;Here is the extraction step from the tax preparation demo, copied from the suite’s dsl.rb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;extract&lt;/span&gt; &lt;span class="ss"&gt;:understanding&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="ss"&gt;:situation&lt;/span&gt;
  &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="s2"&gt;"Extract the taxpayer's situation from their description. Only fill a "&lt;/span&gt; &lt;span class="p"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"field when the text genuinely supports it — leave anything the writer "&lt;/span&gt; &lt;span class="p"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"did not mention unset rather than guessing. ..."&lt;/span&gt;
  &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="ss"&gt;filing_status: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;type: :enum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="ss"&gt;values: &lt;/span&gt;&lt;span class="sx"&gt;%w[single married_jointly married_separately head_of_household]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;dependents: :integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;self_employed: :boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;rental_properties: :integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;sold_investments: :boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;owns_business_entity: :boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;multi_state: :boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;irs_letter: :boolean&lt;/span&gt;
  &lt;span class="n"&gt;transition&lt;/span&gt; &lt;span class="ss"&gt;to: :heard_you&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is what that lead typed (the vague case):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Honestly, this year was a blur and I’ve been avoiding thinking about taxes. I got married in June, so I assume that changes things, though we haven’t talked about how we’re filing. I also picked up a side gig doing grocery deliveries through one of those apps on weekends, which I’m told counts as being self-employed. Somewhere in a kitchen drawer there’s a letter from the IRS that came in August that I haven’t been brave enough to open. We rent our apartment, if that matters. I don’t really know what else you need from me, so please just tell me what to bring.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The correct extraction is not “married filing jointly”. They got married. They said they have not decided how to file. The right answer is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filing_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"self_employed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rental_properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sold_investments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owns_business_entity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"multi_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"irs_letter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two answers, six honest nulls. A model that “helpfully” fills in married_jointly has invented a fact. On this case, &lt;strong&gt;openai/gpt-4o-mini&lt;/strong&gt; scored 78.8% and the diagnostics named the hallucination: expected null, got married_jointly. &lt;strong&gt;deepseek/deepseek-v4.1-flash-1017&lt;/strong&gt; at high effort scored 100% with empty diagnostics.&lt;/p&gt;

&lt;p&gt;So the product question became: which model captures the most answers, assigns them correctly, avoids hallucinations, and does not miss details, while staying cheap and fast?&lt;/p&gt;

&lt;p&gt;A lead who waits 30 seconds for the next question is a lead who closes the tab. At 1,000 leads, $0.0002 per call is twenty cents. $0.50 per call is five hundred dollars.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Qualified.At suite
&lt;/h2&gt;

&lt;p&gt;Qualified.At ships ten demo businesses: bookkeeping, couples therapy, home remodeling, immigration law, managed IT, personal injury, roofing, software development, tax preparation, and wedding photography. Each one became a dataset. Each dataset has four case variants.&lt;/p&gt;

&lt;p&gt;Here is the thing that bit me. The report had a dozen sortable columns: accuracy, exact match, hallucination rate, errors, latency, cost, tokens. Sort by accuracy and an expensive, slow model wins. Sort by cost and a model that hallucinates wins. Sort by primary score and you crow a token hog. None of those columns answered the question I actually had, which was which one should I ship?&lt;/p&gt;

&lt;p&gt;So &lt;a href="https://rubygems.org/gems/llm-evals" rel="noopener noreferrer"&gt;llm-evals&lt;/a&gt; lets you say what matters, in YAML, and folds it into one column called w &lt;strong&gt;wscore&lt;/strong&gt;. This is the weighted_score section from Qualified.At’s .llm-evals.config.yml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;weighted_score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weights&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Positive metrics (higher is better)&lt;/span&gt;
    &lt;span class="na"&gt;exact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;              &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="c1"&gt;# Exact contributes high number&lt;/span&gt;
    &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="c1"&gt;# This is a score computed in other ways&lt;/span&gt;
    &lt;span class="c1"&gt;# Negative metrics (lower value is better)&lt;/span&gt;
    &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;            &lt;span class="s"&gt;-5&lt;/span&gt; &lt;span class="c1"&gt;# Latency is important, but relatively less than others&lt;/span&gt;
    &lt;span class="na"&gt;cost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="s"&gt;-5&lt;/span&gt; &lt;span class="c1"&gt;# Same with the cost&lt;/span&gt;
    &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="s"&gt;-3&lt;/span&gt; &lt;span class="c1"&gt;# latency contributes relatively low comparatively&lt;/span&gt;
    &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="s"&gt;-8&lt;/span&gt; &lt;span class="c1"&gt;# and any errors contribute quite a bit to the total&lt;/span&gt;
    &lt;span class="na"&gt;hallucinated_rate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;-6&lt;/span&gt; &lt;span class="c1"&gt;# Hallucinations are bad, and are penalized&lt;/span&gt;
    &lt;span class="na"&gt;invalid_rate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;-8&lt;/span&gt; &lt;span class="c1"&gt;# invalid_rate is an error, so heavy penalty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A positive weight means higher is better. A negative weight means lower is better. Raw weights are hard to reason about, though, so the llm-evals CLI ships a subcommand that does the math for you. I called it weights, which is not my most imaginative work. Run evals weights and it reads your &lt;code&gt;.llm-evals.config.yml&lt;/code&gt;, then prints each column’s direction and its real share of the final score:&lt;/p&gt;

&lt;p&gt;The help screen of &lt;code&gt;evals weights -h&lt;/code&gt;: print how the config file's weights divide the weighted score, with -C to point at another lineup file.&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%2F7t708ysqyzqjzazbfqal.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%2F7t708ysqyzqjzazbfqal.png" alt="evals weights -h" width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what it prints for the Qualified.At config:&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%2Fa13xvccv77by2vvh69pm.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%2Fa13xvccv77by2vvh69pm.png" alt="weights" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that table as a product brief. Exact alone is 20% of the score: I want every field right, not almost right. Errors and invalid together are 32%: a broken reply is worse than a slow one. Hallucinations are 12%. Cost is only 10%. Latency is 10%. Wall time is 6%. That is not a universal truth. That is what this intake product cares about.&lt;/p&gt;

&lt;p&gt;The formula is deliberately boring:&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%2F8j4d8bstbrz0u1oldw8i.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%2F8j4d8bstbrz0u1oldw8i.jpeg" alt="weight formula" width="800" height="103"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each metric’s goodness is its value, or 1 - value under a negative weight. cost and time go on a log scale between the cheapest and most expensive contender in the table, so each doubling costs the same penalty, no matter how absurd the priciest model is. Errors come straight off the top: 20 points for the first, 25 for two, and so on.&lt;/p&gt;

&lt;p&gt;The weights are opinions, and that is the point. They force you to write down the trade-off you were going to make in your head anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Please &lt;a href="https://kig.re/2026/11/02/llm-evals-is-a-ruby-gem-for-testing-probabilistic-assumptions-in-your-AI-datasets.html" rel="noopener noreferrer"&gt;see the original post for the full result set&lt;/a&gt;. There are more details there. &lt;/p&gt;

&lt;h2&gt;
  
  
  How the gem is shaped
&lt;/h2&gt;

&lt;p&gt;You write two small classes. The gem does everything else.&lt;/p&gt;

&lt;p&gt;The adapter runs one case against one model and returns a Reply. It owns everything the model sees and everything done to its answer. In Qualified.At, it calls the same prompt builder and the same parser production uses, so the scores reflect production rather than a lab version of it.&lt;/p&gt;

&lt;p&gt;The scorer turns a case and a reply into a Score with whatever metrics you declare. It never calls anything.&lt;/p&gt;

&lt;p&gt;The gem handles case discovery, the model lineup, providers (OpenRouter by default, through RubyLLM), rate limits, retries, thread pools, progress bars, the leaderboard, an HTML report, and optional reporting to Braintrust or Logfire. Some of the design came from Pydantic Evals.&lt;/p&gt;

&lt;p&gt;A few decisions I am stubborn about:&lt;br&gt;
A wrong answer is a 0. A network error is an error, not a 0. Your model did not answer wrongly because OpenRouter returned a 503.&lt;/p&gt;

&lt;p&gt;A model the suite cannot ask is refused by name, not scored. If your suite sends chat prompts and the lineup lists a typed-question model, you get typesafe/jev-latest refused: needs typesafe; this suite produces prompt before anything runs.&lt;/p&gt;

&lt;p&gt;Settings multiply. effort: [low, high] with temperature: [0.0, 0.7] becomes four contenders for that model. You are not comparing models; you are comparing configurations.&lt;/p&gt;

&lt;p&gt;No lock-in. A local JSON log is always written. Any HTML report can be re-rendered from it later with evals render, with no model calls.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The repository ships two example suites that use fake models, so they need no API keys and no network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kigster/llm-evals.git
&lt;span class="nb"&gt;cd &lt;/span&gt;llm-evals
bundle &lt;span class="nb"&gt;install
&lt;/span&gt;just examples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For your own project, copy the toy suite’s evals/ folder, swap in your adapter and scorer, list real models under openrouter: in &lt;code&gt;.llm-evals.config.yml&lt;/code&gt;, set OPENROUTER_API_KEY, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;evals cases        &lt;span class="c"&gt;# list the cases; calls nothing&lt;/span&gt;
evals verify       &lt;span class="c"&gt;# offline checks of inputs, expected answers, adapter and scorer&lt;/span&gt;
evals compare &lt;span class="nt"&gt;-n&lt;/span&gt;   &lt;span class="c"&gt;# show the experiment matrix; calls nothing&lt;/span&gt;
evals compare      &lt;span class="c"&gt;# run it, and open the report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run evals compare -n before every paid run. It prints the full experiment matrix, so you find out that you accidentally multiplied three efforts by three temperatures by twelve models before OpenRouter does.&lt;/p&gt;

&lt;p&gt;The gem is MIT-licensed, and the README covers writing providers (there is a full Ollama example) and trackers (there is a W&amp;amp;B Weave sketch). Issues and pull requests are very welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;There is no best model. There is only the best model for a given task, at a given price, with a given tolerance for being confidently wrong. DeepSeek won my suite overall, and it still lost three datasets to &lt;strong&gt;gpt-6-luna&lt;/strong&gt; and one to &lt;strong&gt;gpt-4o-mini&lt;/strong&gt;. If I had picked one winner and wired it into every extract step, I would have shipped worse answers to a third of my customers and called it a decision.&lt;/p&gt;

&lt;p&gt;The leaderboard is not the point. The point is that the question “which model should we use?” stops being an argument and becomes a command you can rerun next week, when the next model drops and the prices move again. Write the cases your product cannot afford to get wrong. Choose weights that say what you actually care about. Then let the numbers settle it.&lt;/p&gt;

&lt;p&gt;And if you are about to write scripts/eval.rb for the fourth time, please don’t. I already did. Home-made tastes better when someone else rolled the dough.&lt;/p&gt;

</description>
      <category>evals</category>
      <category>ai</category>
      <category>ruby</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Managing Secrets in Development environments</title>
      <dc:creator>Konstantin Gredeskoul</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:44:17 +0000</pubDate>
      <link>https://dev.to/kigster/managing-secrets-in-development-environments-o13</link>
      <guid>https://dev.to/kigster/managing-secrets-in-development-environments-o13</guid>
      <description>&lt;p&gt;I write a lot of code, open source, and as part of large engineering teams. How poorly teams manage their development secrets, some that often overlap with production blows my mind. So I coded what I hope is a decent solution.&lt;/p&gt;

&lt;p&gt;So, I don't know how prevalent or wide spread this problem is. But here it is: you join a new team and someone slacks you the .env file filled with 50+ API keys, client ids, secrets, it's a shell-like file, so some come with comments "please go sign up and replace this token with your own". But others don't. The entire team uses one key for a service  X. And among the keys that folks share in development may just a few: three to five, are the same as the production keys. Noice. Oh wait, .env is git-ignored. Phew!&lt;/p&gt;

&lt;p&gt;Cause nobody ever made a copy and accidentally committed it, as .env-alex and that fuzzy and warm git ignore rule only mentioned **/.env, so .env-alex went through and, well we squash merged the branches, so we are ok. Right? Gosh. God no. GitHub support ticket to kill your orphan commit may not be enough.&lt;/p&gt;

&lt;p&gt;The onboarding wiki says "get the .env from a fellow developer, using slack, email or airdrop" and then change the couple that require individual logins.&lt;/p&gt;

&lt;p&gt;Ever since I worked at Coinbase, and on my first week on the Infra team the guy sitting next to me ended up getting phished, resulting in him delivering his laptop to the op-sec team and that day they identified not one but two zero days in Firefox by air-gapping his laptop. Someone had spent about a mil on dark web to get into his laptop! (I should&lt;br&gt;
Since then, and especially now with all the AI agents about to become leet hackers, I am just in shock at how some engineers are so clueless about Whats about to happen!&lt;/p&gt;

&lt;p&gt;My personal recommendation, if you are on GMail as your personal, and you are not using Advanced Protection with hardware keys, you will be hacked in the next year or so.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://landing.google.com/advancedprotection/" rel="noopener noreferrer"&gt;https://landing.google.com/advancedprotection/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anyway. In the last six years i worked on four or five projects where dev secrets were a very fleeting afterthought. So this year I wrote a tool that does not compromise on security but gives you a safe way to keep your goddamn secrets in git and not stress about it.&lt;/p&gt;

&lt;p&gt;It's a Rust based CLI tool that's a simple orchestrator on top of SOPS, age and age hardware plugins. Ok Mac with new Silicon processor it uses Secure Enclave to mint the keys. Or a YubiKey.&lt;/p&gt;

&lt;p&gt;I am looking for feedback and beta testers! This is a free tool distributed under the MIT license. Adoption is what I worry about.&lt;br&gt;
So please, give it a whirl.&lt;br&gt;
Version 2.0.0 is out, supporting YubiKeys and a slew of new features.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sopsy-cli.dev/" rel="noopener noreferrer"&gt;https://sopsy-cli.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks!&lt;br&gt;
Konstantin&lt;/p&gt;

</description>
      <category>sops</category>
      <category>age</category>
      <category>encryption</category>
      <category>development</category>
    </item>
    <item>
      <title>Sopsy: Secrets in Git, Sealed by the Secure Enclave</title>
      <dc:creator>Konstantin Gredeskoul</dc:creator>
      <pubDate>Sat, 22 Aug 2026 00:31:16 +0000</pubDate>
      <link>https://dev.to/kigster/sopsy-secrets-in-git-sealed-by-the-secure-enclave-57f1</link>
      <guid>https://dev.to/kigster/sopsy-secrets-in-git-sealed-by-the-secure-enclave-57f1</guid>
      <description>&lt;p&gt;Well, I did a thing I've wanted to do for a while: I published &lt;strong&gt;&lt;a href="https://github.com/kigster/sopsy" rel="noopener noreferrer"&gt;sopsy&lt;/a&gt;&lt;/strong&gt; — my &lt;strong&gt;first open-source package written in Rust&lt;/strong&gt; — to &lt;a href="https://crates.io/crates/sopsy" rel="noopener noreferrer"&gt;crates.io&lt;/a&gt; and to Homebrew. After a couple of decades of shipping Ruby, some C, and an unreasonable amount of Bash, watching &lt;code&gt;cargo publish&lt;/code&gt; go green on a crate of my own felt genuinely great. I am allowing myself a small victory lap before Rust inevitably finds a new way to humble me.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;And like all the "cool kids" these days, of course it has its own snazzy website, because apparently a CLI is not real until it has a domain name. Visit &lt;a href="https://sopsy-cli.dev" rel="noopener noreferrer"&gt;sopsy-cli.dev&lt;/a&gt; now, or after you're done reading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is it, and why might you care?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;sopsy lets your team commit its secrets — encrypted — directly into git, where each developer holds their own private key inside their Mac's Secure Enclave, and decryption takes a fingerprint.&lt;/strong&gt; There's no shared master key (the single thing that ultimately sank my last attempt at this, more on that below). No secrets server to run or pay for. No plaintext &lt;code&gt;.env&lt;/code&gt; quietly making the rounds on Slack. Adding a teammate is a pull request; removing one re-keys every file automatically.&lt;/p&gt;

&lt;p&gt;Who is it for? Teams that want &lt;strong&gt;tight, revocable control over exactly who can decrypt the developer secrets they keep in git&lt;/strong&gt; — small-to-mid engineering teams, open-source projects, and security-conscious startups that would rather not stand up Vault just to stop passing &lt;code&gt;.env&lt;/code&gt; files around by hand. It is deliberately bottom-up: one developer installs it, tries it on a repo, and brings the rest of the team along. macOS-first for the hardware bits, today.&lt;/p&gt;

&lt;p&gt;The mental shift, compressed for the busy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A shared symmetric key (or a &lt;code&gt;.gitignore&lt;/code&gt;) asks you to &lt;strong&gt;guard one secret everyone copies.&lt;/strong&gt;&lt;br&gt;
sopsy lets each person &lt;strong&gt;hold their own key in hardware&lt;/strong&gt; — and re-keys the team when membership changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What it actually is
&lt;/h2&gt;

&lt;p&gt;First, the honest framing, because security tools get insufferable very quickly: &lt;strong&gt;sopsy does not reimplement any cryptography.&lt;/strong&gt; It stands on three excellent, boring, battle-tested tools that are kind of annoying to learn and use, and makes them, in my opinion, a lot more pleasant to live with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/getsops/sops" rel="noopener noreferrer"&gt;SOPS&lt;/a&gt;&lt;/strong&gt; — Mozilla's secrets operator, which cleverly encrypts the &lt;em&gt;values&lt;/em&gt; in a file while leaving the &lt;em&gt;keys&lt;/em&gt; readable, so a diff still tells you "&lt;code&gt;DATABASE_URL&lt;/code&gt; changed" or when the "&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;" was added, without leaking what it changed to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;age&lt;/a&gt;&lt;/strong&gt; — Filippo Valsorda's modern, no-options-to-get-wrong encryption, the spiritual antidote to GPG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/remko/age-plugin-se" rel="noopener noreferrer"&gt;age-plugin-se&lt;/a&gt;&lt;/strong&gt; — the bit that made me warm and fuzzy: it generates an age identity whose &lt;strong&gt;private key is born inside your Mac's Secure Enclave and physically cannot leave it.&lt;/strong&gt; Decryption requires Touch ID. The key isn't on disk for malware to find, because it was never on disk at all. The fact that macOS has a &lt;a href="https://support.apple.com/guide/security/the-secure-enclave-sec59b0b31ff/web" rel="noopener noreferrer"&gt;Secure Enclave&lt;/a&gt; sitting there, quietly waiting to solve exactly this problem, was a huge surprise to me. But at the same time, it wasn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;sopsy is the friendly Rust CLI that orchestrates all three: it bootstraps a repo, manages who can decrypt, re-keys everything when membership changes, and ships a CI gate that fails the build the instant a plaintext secret sneaks in. SOPS stays the engine; sopsy is the steering wheel, the seatbelt, and the annoying little dashboard light that tells you when you are about to do something dumb.&lt;/p&gt;

&lt;p&gt;Here's the shape of it:&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%2Fpebi7vpqp1uallcnnj9f.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%2Fpebi7vpqp1uallcnnj9f.jpeg" alt="How Key Minting Works" width="800" height="1188"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole trick is that arrow at the bottom. Encryption only needs your teammates' &lt;strong&gt;public&lt;/strong&gt; keys, so it's silent and offline. Decryption needs the &lt;strong&gt;private&lt;/strong&gt; key, which lives in silicon and answers only to your fingerprint. The ciphertext rides happily in git; the thing that unlocks it never does.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five-minute tour
&lt;/h3&gt;

&lt;p&gt;You start a repo the way you'd hope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init my-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;my-app
sopsy init          &lt;span class="c"&gt;# checks your tools, mints a Secure Enclave identity, writes the config&lt;/span&gt;
sopsy edit .env.encrypted   &lt;span class="c"&gt;# opens your $EDITOR; decrypts in, re-encrypts on save (Touch ID)&lt;/span&gt;
sopsy check         &lt;span class="c"&gt;# the CI gate: fails if any plaintext secret is tracked &lt;/span&gt;
git add .sops.yaml .sopsy.yml .env.example .env.encrypted .gitignore
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Encrypted secrets, managed by sopsy"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sopsy init&lt;/code&gt; prints your &lt;strong&gt;public recipient&lt;/strong&gt; — an &lt;code&gt;age1se1…&lt;/code&gt; string — and that's the only part of your identity that's ever shared or committed. For day-to-day use there's also &lt;code&gt;sopsy decrypt&lt;/code&gt; (and &lt;code&gt;sopsy secrets decrypt&lt;/code&gt;), which streams plaintext to stdout, so you can wire it into &lt;a href="https://direnv.net" rel="noopener noreferrer"&gt;direnv&lt;/a&gt; and unlock the whole environment once per shell instead of fighting it per-command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .envrc — one Touch ID when you cd in, then your secrets are just there&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;sopsy decrypt .env.encrypted | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'/^#/d; /^$/d; s/^([A-Z])/export \1/g'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;If Touch ID on every read gets old — you live in &lt;code&gt;direnv&lt;/code&gt;, or you run a dozen windows on one project — you can mint your Enclave key &lt;strong&gt;without&lt;/strong&gt; a biometric gate: &lt;code&gt;sopsy join "Your Name" --without-touch-id&lt;/code&gt;. The private key still never leaves the Enclave and is still device-bound; it just no longer prompts. This is a deliberate trade: less thumb, less ceremony.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Where it gets good: the team model
&lt;/h3&gt;

&lt;p&gt;A shared symmetric key has no notion of "who." sopsy does, and that's the whole reason it works for a team. Two files carry the state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.sops.yaml&lt;/code&gt;&lt;/strong&gt; — consumed by SOPS itself: a list of &lt;strong&gt;public&lt;/strong&gt; recipient keys that any new secret gets encrypted to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.sopsy.yml&lt;/code&gt;&lt;/strong&gt; — sopsy's own ledger: human names, who owns which key, lifecycle state, a small &lt;strong&gt;audit trail&lt;/strong&gt; (who requested access and when, who approved them and when), and the break-glass marker. A committed &lt;code&gt;.sopsy.sha&lt;/code&gt; checksum makes hand-edits tamper-evident instead of silently trusted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onboarding is &lt;strong&gt;self-service&lt;/strong&gt;, which is the part that actually changes how a team feels. A newcomer doesn't chase anyone for a key; they mint their own and send the &lt;em&gt;public&lt;/em&gt; half in a pull request:&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%2F7ooiseiehu23bbtg3l7q.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%2F7ooiseiehu23bbtg3l7q.png" alt="Team Onboarding" width="273" height="1093"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note what a "pending" join grants: &lt;strong&gt;nothing.&lt;/strong&gt; The newcomer's key isn't in &lt;code&gt;.sops.yaml&lt;/code&gt; yet, so it can't decrypt anything — the request is just a public key sitting in a PR. (&lt;code&gt;sopsy join&lt;/code&gt; is also aliased &lt;code&gt;sopsy request-access&lt;/code&gt;, if that reads better in your team's docs.) An existing member reviews it, vouches that the key really belongs to Anna — you confirm that out-of-band, the same way you'd confirm a GPG fingerprint — and &lt;code&gt;sopsy approve&lt;/code&gt; does the rest: adds the key, re-wraps the data key for the new recipient set with &lt;code&gt;sops updatekeys&lt;/code&gt;, and flips Anna to active. Offboarding is the mirror image. No shared secret, no master password, no "we should probably rotate that someday."&lt;/p&gt;

&lt;p&gt;And because every developer's private key is hardware-bound, there are two deliberate safety valves for the cases where a fingerprint isn't available:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Break-glass&lt;/strong&gt; — a portable age key you generate once, store offline in 1Password, and register as an emergency recipient. If everyone's laptop falls in the ocean, that one offline key can still open and re-key the repo. &lt;code&gt;sopsy&lt;/code&gt; nags you until you make one, and after this week I understand exactly why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI decryption&lt;/strong&gt; — &lt;code&gt;sopsy recipient ci&lt;/code&gt; runs the same guided ceremony to hand your pipeline a portable key: one CI secret named &lt;code&gt;SOPS_AGE_KEY&lt;/code&gt;, and your workflows can decrypt. The Secure Enclave, correctly, will never work headless — so this is how CI reads secrets without one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A little history: how I got here
&lt;/h2&gt;

&lt;p&gt;Nine years ago I wrote a little tool called &lt;a href="///2017/03/10/dead-simple-encryption-with-sym.html"&gt;sym&lt;/a&gt; and wrote a blog post about it.&lt;/p&gt;

&lt;p&gt;I opened the post with a complaint that has aged distressingly well: keeping your secrets &lt;em&gt;out&lt;/em&gt; of git, in a pile of &lt;code&gt;.gitignored&lt;/code&gt; files, gives you a &lt;strong&gt;false sense of security&lt;/strong&gt; and costs you everything good about version control. No history. No rollback. No diff. And the eternal team tax of "hey, can you AirDrop me the &lt;code&gt;.env&lt;/code&gt; again? mine's three weeks stale."&lt;/p&gt;

&lt;p&gt;The dream and ambition back then was not quite the same as today, but close: I just &lt;em&gt;didn't want to have plain text secrets lying around in my repos&lt;/em&gt;, even if they were git-ignored. Ideally, I wanted to encrypt them and check them in. Pre-commit hooks existed back then, so committing a plaintext secret to git was less of an issue. But "keys to many kingdoms" just lying around on my file system in plain text, waiting for someone to break in and steal them — that bothered me, annoyingly and continuously. So I wrote &lt;em&gt;sym&lt;/em&gt; — a symmetric encryption Ruby gem with a couple of macOS-specific twists. And for 2017 it wasn't so bad.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;sym&lt;/code&gt; is a Ruby gem that used OpenSSL encryption underneath, so that part was solid. You generated a symmetric key, and you could encrypt your secrets and commit them to your repo as one text blob (encrypted data was also base64 encoded). You had one master key in 1Password, and when you "onboarded" someone, you'd grab that master key, and they would import that key using &lt;code&gt;sym&lt;/code&gt; into OS X Keychain, in the process wrapping the key in an additional layer of password-protected encryption. Now you could git-commit the secrets, and to decrypt them on your machine required your password (whatever you assigned during the import). That was also a bonus. &lt;strong&gt;But a single master key was now the one thing that, if leaked, rendered the entire process completely pointless.&lt;/strong&gt; And in that sense, some security people would say things like &lt;em&gt;"Great tool, it gives you a warm and fuzzy feeling of protection, without actually providing it"...&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It had a few conveniences too — you could request that your password be cached in a local memcached instance, although there I got lazy and cached it in clear text. Security via obscurity, right?&lt;/p&gt;

&lt;p&gt;Still, it was way better than everyone sharing a plain text file with two dozen API keys that, if stolen, could have been used to rack up significant fees for the company, not to mention access some private data, which would have been way worse.&lt;/p&gt;

&lt;p&gt;So? That was fine for 2017, and a few projects I was on adopted it. It did not become a hit, because shortly thereafter Rails launched credentials, which, although it lacked the convenience of the password-protected and Keychain-stored encryption key, ultimately allowed you to keep encrypted credentials in the repo. With the popularity of Rails, there was no competition.&lt;/p&gt;

&lt;p&gt;Having recently been on a project where the &lt;code&gt;.env&lt;/code&gt; file contained nearly one hundred various secrets and API keys, I thought to myself, maybe we can do better in 2026?&lt;/p&gt;

&lt;h3&gt;
  
  
  The research
&lt;/h3&gt;

&lt;p&gt;After numerous conversations with Claude and ChatGPT about the best existing secrets-encryption tools for developers, and specifically — &lt;em&gt;in the development environment&lt;/em&gt; — I kept coming back to the same question.&lt;/p&gt;

&lt;p&gt;Production systems in the cloud have matured. If you're running on AWS, your keys are in KMS. If you're a Terraform nut, probably Vault. But locally, on your development MacBook, what is the preferred method to avoid keeping "keys to the kingdoms" lying around in plain text?&lt;/p&gt;

&lt;p&gt;Surely, the tool that encrypts your dev secrets and does not depend on a single symmetric key has been written by now? Well, maybe it has. But the three of us (the two AI agents and myself) could not find anything that did what I wanted, with the ease of use I had in mind.&lt;/p&gt;

&lt;p&gt;Having identified a potential hole in the developer tooling, and having collected information about the modern tools people use for encryption and key management, I concocted a plan. So over the last few days Claude and I built &lt;strong&gt;&lt;a href="https://github.com/kigster/sopsy" rel="noopener noreferrer"&gt;sopsy&lt;/a&gt;&lt;/strong&gt; — a Rust-based CLI for doing what I wanted in 2017, but could not quite build then without hand-waving around the most important part.&lt;/p&gt;

&lt;h2&gt;
  
  
  A parade of excellent bugs
&lt;/h2&gt;

&lt;p&gt;Now the fun part — and the honest part. I built sopsy over a handful of days pairing with an AI coding agent, and here's the detail I keep turning over: &lt;strong&gt;the agent wrote clean, well-tested code that passed its own suite green, and almost every real bug was found the moment a human actually &lt;em&gt;used&lt;/em&gt; the thing.&lt;/strong&gt; Not in CI. In my hands, on two real macOS accounts, with my actual thumb. That gap — between "the tests pass" and "it works for a person" — is the whole story of this section, and frankly the whole story of &lt;a href="///2026/06/22/writing-evals-for-ai-powered-apps.html"&gt;evals&lt;/a&gt; too. Determinism in the test suite is not correctness in the world.&lt;/p&gt;

&lt;p&gt;Here are the ones worth your time.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The key that wasn't there
&lt;/h3&gt;

&lt;p&gt;The first real run of &lt;code&gt;sopsy init&lt;/code&gt; ended with the most confusing class of failure: success, then nothing worked. It generated my Secure Enclave identity, encrypted the file, reported victory — and then every decrypt failed with &lt;code&gt;age: identity did not match any of the recipients&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause is a beautiful little misunderstanding about how &lt;code&gt;age-plugin-se&lt;/code&gt; works. When it generates a key, it hands you back a long &lt;code&gt;AGE-PLUGIN-SE-1…&lt;/code&gt; string. It is tempting — and wrong — to treat that string as a throwaway, because "the private key is in the Enclave, so who cares." But that string is the &lt;strong&gt;handle&lt;/strong&gt;: the reference SOPS needs to ask the Enclave to unwrap anything. We were generating it and dropping it on the floor. The private key was safe in the Enclave, all right — and totally unaddressable, like a safe deposit box whose number you dropped in the toilet and accidentally flushed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The fix is the distinction worth internalizing: &lt;strong&gt;the handle is not the secret.&lt;/strong&gt; It only works on &lt;em&gt;this&lt;/em&gt; device, behind &lt;em&gt;this&lt;/em&gt; fingerprint, so it is perfectly safe to write to disk — and you &lt;em&gt;must&lt;/em&gt;, or the Enclave key is useless. &lt;code&gt;sopsy&lt;/code&gt; now persists it to the standard &lt;code&gt;sops&lt;/code&gt; keys file and points every SOPS call at it. The private key still never leaves the chip. The map to it just needs to exist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. The &lt;code&gt;init&lt;/code&gt; that ran forever
&lt;/h3&gt;

&lt;p&gt;Early on, a teammate (me, in a different mood) ran &lt;code&gt;sopsy init&lt;/code&gt; in the wrong directory — one that wasn't a fresh repo, so git happily walked &lt;em&gt;up&lt;/em&gt; and decided the enclosing repository was my entire home folder. sopsy then tried to find every encrypted file to re-key it by... reading every file under the repo root. All of &lt;code&gt;$HOME&lt;/code&gt;. It was still going, fans spinning, several minutes and a few gigabytes of &lt;code&gt;node_modules&lt;/code&gt; later.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An unbounded "scan everything" is a bug that hides until someone points it at a big enough directory, and then it's a hang, not an error — the worst kind. The fix was to stop walking the filesystem and instead expand the declared encrypted-file globs directly (a &lt;code&gt;*&lt;/code&gt; that never crosses a &lt;code&gt;/&lt;/code&gt;), so the scan touches the directories the patterns actually name and nothing else. And &lt;code&gt;init&lt;/code&gt; now refuses to silently adopt your home directory as a secrets repo — it stops and asks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. The secret that couldn't be committed (and then couldn't be shared)
&lt;/h3&gt;

&lt;p&gt;This one was a two-part trap, and it's my favorite because each half was invisible without the other.&lt;/p&gt;

&lt;p&gt;The default &lt;code&gt;.gitignore&lt;/code&gt; sopsy wrote was a touch too greedy. It ignored &lt;code&gt;.env.*&lt;/code&gt; to keep stray dotenv files out of git — sensible! — but &lt;code&gt;.env.example.encrypted&lt;/code&gt; matches &lt;code&gt;.env.*&lt;/code&gt; too. So the one file you &lt;em&gt;wanted&lt;/em&gt; committed was quietly untracked. That's part one.&lt;/p&gt;

&lt;p&gt;Part two: when a new member was approved, the re-key step enumerated files via &lt;code&gt;git ls-files&lt;/code&gt;. Reasonable, except that command deliberately skips ignored files — so the encrypted artifact that part one had just hidden was &lt;em&gt;also&lt;/em&gt; skipped during re-keying. The new member got added to &lt;code&gt;.sops.yaml&lt;/code&gt;, everything reported success, and then they couldn't decrypt one of the files, because they'd never been added to it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The lesson is a clean little invariant: &lt;strong&gt;encrypted artifacts are first-class committed files, full stop.&lt;/strong&gt; The gitignore now explicitly rescues every &lt;code&gt;*.encrypted&lt;/code&gt; no matter what, and re-keying enumerates secrets by globbing the working tree — not by asking git, which has opinions about what's worth mentioning. When two reasonable behaviors compose into one wrong behavior, the invariant was wrong or incomplete. That is where the bug was hiding.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;sudo&lt;/code&gt; cannot touch the Enclave (and that's the point)
&lt;/h3&gt;

&lt;p&gt;I tested the two-person flow with two macOS accounts on one laptop, and switched between them with &lt;code&gt;sudo su - arc&lt;/code&gt;. Decryption failed every time, no Touch ID prompt, just the same flat "no identity matched." I was &lt;em&gt;certain&lt;/em&gt; this was a sopsy bug. It is not a sopsy bug.&lt;/p&gt;

&lt;p&gt;The Secure Enclave will only release a key to the user's &lt;strong&gt;active GUI login session.&lt;/strong&gt; &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;su&lt;/code&gt;, SSH — none of them can raise the biometric prompt, so the Enclave just says no. The instant I used &lt;strong&gt;Fast User Switching&lt;/strong&gt; to actually log &lt;code&gt;arc&lt;/code&gt; in at the screen, the prompt appeared, my thumb did its thing, and the secret opened.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This isn't a wart, it's the &lt;strong&gt;threat model doing its job.&lt;/strong&gt; A key you can use over SSH is a key an attacker can use over SSH. Hardware-bound, presence-required decryption is exactly what you want for production secrets — and it's precisely why the portable break-glass and CI keys exist for the headless cases where the Enclave, correctly, won't play. Touch ID is per-account, too: each user enrolls their own fingerprint, falling back to that account's login password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There were smaller ones — my own tests cheerfully writing fake keys into my &lt;em&gt;real&lt;/em&gt; keystore, a Homebrew install shadowing the freshly-built &lt;code&gt;cargo&lt;/code&gt; binary on &lt;code&gt;$PATH&lt;/code&gt; so I kept testing the old bits — but the four above are the ones that taught me something. Every single one survived a green test suite and died on contact with a human. Annoying? Yes. Useful? Also yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  sopsy in one glance
&lt;/h2&gt;

&lt;p&gt;The whole feature set, for the skimmers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One-command bootstrap&lt;/strong&gt; — &lt;code&gt;sopsy init&lt;/code&gt; writes &lt;code&gt;.sops.yaml&lt;/code&gt;, &lt;code&gt;.env.example&lt;/code&gt;, an encrypted &lt;code&gt;.env.encrypted&lt;/code&gt;, &lt;code&gt;.gitignore&lt;/code&gt; safety rules, and &lt;code&gt;.sopsy.yml&lt;/code&gt;. Idempotent, so re-running is always safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure Enclave identities&lt;/strong&gt; — your private key is generated inside Apple Silicon hardware, gated by Touch ID, and impossible to read or exfiltrate. &lt;code&gt;--without-touch-id&lt;/code&gt; if you want it device-bound but prompt-free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-service membership with an audit trail&lt;/strong&gt; — &lt;code&gt;sopsy join&lt;/code&gt; (alias &lt;code&gt;request-access&lt;/code&gt;) records a &lt;em&gt;pending&lt;/em&gt; request; any active member &lt;code&gt;sopsy approve&lt;/code&gt;s. &lt;code&gt;.sopsy.yml&lt;/code&gt; keeps who asked, their username, when, and who approved them and when; &lt;code&gt;sopsy recipient list&lt;/code&gt; prints it all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic re-keying&lt;/strong&gt; — every add / remove / approve re-wraps all secrets via &lt;code&gt;sops updatekeys&lt;/code&gt;, so the recipient set never drifts from the ciphertext.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friendly edits + scriptable crypto&lt;/strong&gt; — &lt;code&gt;sopsy edit&lt;/code&gt; opens an encrypted file in your &lt;code&gt;$EDITOR&lt;/code&gt; with auto file-type detection; &lt;code&gt;sopsy encrypt&lt;/code&gt; / &lt;code&gt;sopsy decrypt&lt;/code&gt; (and the &lt;code&gt;secrets&lt;/code&gt; forms) do one-shot, &lt;code&gt;direnv&lt;/code&gt;-friendly crypto to stdout or a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break-glass + CI ceremonies&lt;/strong&gt; — &lt;code&gt;sopsy recipient break-glass&lt;/code&gt; mints an offline emergency key; &lt;code&gt;sopsy recipient ci&lt;/code&gt; mints a portable CI decryption key (one &lt;code&gt;SOPS_AGE_KEY&lt;/code&gt; secret and your pipeline can decrypt).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tamper-evident config&lt;/strong&gt; — a committed &lt;code&gt;.sopsy.sha&lt;/code&gt; checksum over &lt;code&gt;.sopsy.yml&lt;/code&gt; + the admin key is refreshed on write and verified on read, so hand-edits are surfaced, not silently trusted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CI gate that bites&lt;/strong&gt; — &lt;code&gt;sopsy check&lt;/code&gt; enforces hygiene invariants and exits non-zero on any tracked plaintext. It needs no key, so it runs on a Linux runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sopsy doctor&lt;/code&gt;&lt;/strong&gt; — a grouped, colorful health report of your tools, Secure Enclave, Touch ID, and repo that you can paste straight into a GitHub issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you keep secrets in git today — or you've been getting away with a shared &lt;code&gt;.env&lt;/code&gt; and quietly worrying about it — I'd genuinely love to hear how your team handles this. What are you using? What would stop you from committing encrypted secrets right next to the code? Install it (&lt;code&gt;brew install kigster/tap/sopsy&lt;/code&gt; or &lt;code&gt;cargo install sopsy&lt;/code&gt;), point it at a throwaway repo, and tell me what breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave a comment below&lt;/strong&gt; — bug reports, "you reinvented X," and "why not just use Y" are all equally welcome. And if it's useful to you, a star on &lt;a href="https://github.com/kigster/sopsy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps more people find it.&lt;/p&gt;

&lt;p&gt;Go encrypt something. Then try to commit it. That part is supposed to feel good now.&lt;/p&gt;

&lt;p&gt;— Konstantin Gredeskoul&lt;/p&gt;

&lt;p&gt;San Francisco, CA, June 28, 2026.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In the spirit of full disclosure&lt;/strong&gt;: sopsy was written over a few days pairing with an AI coding agent, and the experience is the quiet subtext of this whole post. The agent was a frankly excellent programmer — clean Rust, real tests, all green. And it could not, on its own, find a single one of the bugs above, because every one of them required a human, a thumb, two login sessions, and a fair bit of swearing. The code was AI-built; the &lt;em&gt;correctness&lt;/em&gt; and &lt;em&gt;validation&lt;/em&gt; came from me yelling at Claude, compacting the context more than once, and generally being pretty annoyed with it. Keep a human in the loop, especially the one holding the finger and the fingerprint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/kigster/sopsy" rel="noopener noreferrer"&gt;sopsy on GitHub&lt;/a&gt; — source, install instructions, and the owner/member guides.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://crates.io/crates/sopsy" rel="noopener noreferrer"&gt;sopsy on Crates.io&lt;/a&gt; — where you'd install it from.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/getsops/sops" rel="noopener noreferrer"&gt;SOPS&lt;/a&gt; · &lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;age&lt;/a&gt; · &lt;a href="https://github.com/remko/age-plugin-se" rel="noopener noreferrer"&gt;age-plugin-se&lt;/a&gt; — the three tools sopsy stands on.&lt;/li&gt;
&lt;li&gt;
&lt;a href="///2017/03/10/dead-simple-encryption-with-sym.html"&gt;Dead Simple Encryption with Sym&lt;/a&gt; — the 2016-era ancestor, and the problem statement that still holds.&lt;/li&gt;
&lt;li&gt;
&lt;a href="///2026/06/22/writing-evals-for-ai-powered-apps.html"&gt;Evals: The Unit Tests for the Non-Deterministic Parts of Your App&lt;/a&gt; — more on the gap between "tests pass" and "it works."&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>security</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>What 25 Years of Deterministic Software Engineering Taught Me About Building AI Systems</title>
      <dc:creator>Konstantin Gredeskoul</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:49:46 +0000</pubDate>
      <link>https://dev.to/kigster/what-25-years-of-deterministic-software-engineering-taught-me-about-building-ai-systems-36oa</link>
      <guid>https://dev.to/kigster/what-25-years-of-deterministic-software-engineering-taught-me-about-building-ai-systems-36oa</guid>
      <description>&lt;p&gt;One of the strangest things about AI engineering is that your test suite can be 100% green while your product is getting worse.&lt;/p&gt;

&lt;p&gt;Traditional software taught us to think in absolutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pass/fail&lt;/li&gt;
&lt;li&gt;correct/incorrect&lt;/li&gt;
&lt;li&gt;deterministic/non-deterministic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI systems force us to think in distributions, thresholds, confidence intervals, and trade-offs.&lt;/p&gt;

&lt;p&gt;It’s a subtle shift, but it changes how you build, test, and deploy software.&lt;/p&gt;

&lt;p&gt;I collected some of the lessons I’ve learned while building AI-powered applications and learning evals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kig.re/2026/06/22/writing-evals-for-ai-powered-apps.html" rel="noopener noreferrer"&gt;https://kig.re/2026/06/22/writing-evals-for-ai-powered-apps.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Curious how others are approaching evaluation and regression testing in production AI systems.&lt;/p&gt;

&lt;p&gt;And here is the summary of most recent research on the subject so you don't have to :-)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kig.re/2026/06/22/ai-digest-on-evals.html" rel="noopener noreferrer"&gt;https://kig.re/2026/06/22/ai-digest-on-evals.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>evals</category>
      <category>appliedai</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
