<?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: Christ-loisele Atidegla</title>
    <description>The latest articles on DEV Community by Christ-loisele Atidegla (@catidegla).</description>
    <link>https://dev.to/catidegla</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%2F4116829%2F7fc40148-4497-49f8-bfb0-c9dd8117dae3.png</url>
      <title>DEV Community: Christ-loisele Atidegla</title>
      <link>https://dev.to/catidegla</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/catidegla"/>
    <language>en</language>
    <item>
      <title>Four agent skills I actually use, and the install problem nobody solved cleanly</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:29:17 +0000</pubDate>
      <link>https://dev.to/catidegla/four-agent-skills-i-actually-use-and-the-install-problem-nobody-solved-cleanly-3m5b</link>
      <guid>https://dev.to/catidegla/four-agent-skills-i-actually-use-and-the-install-problem-nobody-solved-cleanly-3m5b</guid>
      <description>&lt;p&gt;Agent skills became a standard faster than most formats do. A directory, a &lt;code&gt;SKILL.md&lt;/code&gt;, and since late 2025 every major harness reads the same thing. Claude Code, Codex, Cursor, Gemini CLI and Antigravity all understand the format.&lt;/p&gt;

&lt;p&gt;What none of them agree on is where the directory goes. &lt;code&gt;~/.claude/skills&lt;/code&gt;, &lt;code&gt;~/.codex/skills&lt;/code&gt;, &lt;code&gt;~/.cursor/skills&lt;/code&gt;, and so on. Use more than one harness, which most of us do now, and you are either symlinking by hand or keeping copies in sync and losing.&lt;/p&gt;

&lt;p&gt;So this is two things: four skills worth having, and an installer that puts them wherever they need to go.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx github:catidegla/skillbelt add &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That detects which harnesses are on your machine and installs into each. No clone, no &lt;code&gt;npm install&lt;/code&gt;, no dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  The skills
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;i18n-parity&lt;/code&gt; is the one I use most and have not seen elsewhere. It compares translation files across locales and reports keys present in one and missing in another, before the missing key ships as a blank string in production. I write bilingual software, French and English, and this class of bug is invisible in review because the reviewer reads one locale.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;laravel-security-review&lt;/code&gt; and &lt;code&gt;nextjs-security-review&lt;/code&gt; encode the checks I would do by hand on a diff: mass assignment, unvalidated file uploads, server actions missing authorisation, environment variables crossing into a client bundle.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;secrets-audit&lt;/code&gt; looks for credentials committed where they should not be, with attention to the shapes that get missed because they do not look like an AWS key.&lt;/p&gt;

&lt;h3&gt;
  
  
  The description rule
&lt;/h3&gt;

&lt;p&gt;There is a validator, and its most important rule is about the &lt;code&gt;description&lt;/code&gt; field. It has to say when to use the skill, not only what it is.&lt;/p&gt;

&lt;p&gt;That description is the only text an agent sees when deciding whether to load a skill. So "use when reviewing a diff, a pull request, or a controller" pulls its weight, and "a skill for Laravel security" does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this sits
&lt;/h3&gt;

&lt;p&gt;The installer half is not a novel idea. &lt;a href="https://www.npmjs.com/package/agent-install" rel="noopener noreferrer"&gt;&lt;code&gt;agent-install&lt;/code&gt;&lt;/a&gt; does the same job and also handles MCP servers and &lt;code&gt;AGENTS.md&lt;/code&gt;, and the &lt;a href="https://www.npmjs.com/package/skills" rel="noopener noreferrer"&gt;&lt;code&gt;skills&lt;/code&gt;&lt;/a&gt; package is building a general registry for the format. For a package manager for skills, use one of those.&lt;/p&gt;

&lt;p&gt;What is here is a curated set of four maintained together and validated as a suite, with installation attached.&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;# Just one skill, just one harness&lt;/span&gt;
npx github:catidegla/skillbelt add i18n-parity &lt;span class="nt"&gt;--harness&lt;/span&gt; claude

&lt;span class="c"&gt;# See where everything would go first&lt;/span&gt;
npx github:catidegla/skillbelt doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/catidegla/skillbelt" rel="noopener noreferrer"&gt;https://github.com/catidegla/skillbelt&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>devtools</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Le Fa, un système binaire à 256 signes du Bénin, et ce qu'il faut pour l'encoder correctement</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:23:42 +0000</pubDate>
      <link>https://dev.to/catidegla/le-fa-un-systeme-binaire-a-256-signes-du-benin-et-ce-quil-faut-pour-lencoder-correctement-3mce</link>
      <guid>https://dev.to/catidegla/le-fa-un-systeme-binaire-a-256-signes-du-benin-et-ce-quil-faut-pour-lencoder-correctement-3mce</guid>
      <description>&lt;p&gt;Le Fa est le système divinatoire des Fon du Bénin, d'où je viens. Les Yoruba le pratiquent sous le nom d'Ifá, et les deux partagent exactement les mêmes figures.&lt;/p&gt;

&lt;p&gt;La structure est binaire, au sens littéral et non métaphorique.&lt;/p&gt;

&lt;h3&gt;
  
  
  Le mécanisme
&lt;/h3&gt;

&lt;p&gt;Une figure comporte deux colonnes. Chaque colonne a quatre positions. Chaque position porte soit une marque, soit deux.&lt;/p&gt;

&lt;p&gt;Quatre positions à deux états donnent seize colonnes possibles. Deux colonnes côte à côte donnent &lt;strong&gt;2⁸ = 256&lt;/strong&gt; signes. Ces seize colonnes sont les seize dù principaux, et les 256 paires forment l'espace complet.&lt;/p&gt;

&lt;p&gt;Deux instruments les produisent. Seize noix de palme, les ikin, manipulées en huit passes. Ou une chaîne de huit coques, opele en yoruba et agumaga en fon, lancée une seule fois. Dans les deux cas la colonne de droite est produite en premier, ce qui n'est pas un détail de présentation : cela détermine le nom du signe obtenu.&lt;/p&gt;

&lt;p&gt;Il existe une règle dans la méthode aux noix qui est l'inverse de ce que tout le monde suppose, moi compris avant vérification :&lt;/p&gt;

&lt;p&gt;Une noix restée dans la main signifie une marque double. Deux noix signifient une marque simple.&lt;/p&gt;

&lt;p&gt;C'est le détail le plus fréquemment recopié de travers dans les descriptions rapides de la procédure, donc la suite de tests l'affirme dans les deux sens, là où un refactor ne peut pas l'inverser en silence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Les relations sont la partie intéressante
&lt;/h3&gt;

&lt;p&gt;Une fois les seize figures écrites comme motifs binaires, une structure apparaît qui vaut le détour pour un programmeur.&lt;/p&gt;

&lt;p&gt;Le renversement : retournez une figure. Quatre des seize se lisent identiquement dans les deux sens : Gbé, Yèku, Woli et Di. Les douze autres s'apparient en six couples.&lt;/p&gt;

&lt;p&gt;Le complément : échangez chaque marque simple contre une double et inversement. Cela apparie les seize en huit couples sans aucun point fixe, ce qui découle du fait que quatre positions ne peuvent jamais toutes différer d'elles-mêmes.&lt;/p&gt;

&lt;p&gt;Ces deux opérations partitionnent le même ensemble de deux manières qui ne coïncident pas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Le difficile, c'était les données
&lt;/h3&gt;

&lt;p&gt;Je pensais que le code serait le travail. Il fait quelques centaines de lignes de manipulation de bits.&lt;/p&gt;

&lt;p&gt;Le difficile a été d'établir ce que sont les figures.&lt;/p&gt;

&lt;p&gt;Les sources divergent. Pas sur les mathématiques, que personne ne conteste, mais sur le nom attaché à chaque figure et sur l'ordre de la liste. L'ordre varie selon les régions : celui que j'utilise est donné par fongbebenin.com pour les dù fon et par ileifa.org pour les odù yoruba, et les deux concordent position par position, mais au moins une liste béninoise publiée place Ka en onzième.&lt;/p&gt;

&lt;p&gt;La bibliothèque traite donc &lt;code&gt;rank&lt;/code&gt; comme un identifiant stable pour l'indexation et précise explicitement que ce n'est pas une affirmation sur la préséance. L'ordre de préséance des 256 dépend des lignées et n'est pas encodé du tout.&lt;/p&gt;

&lt;p&gt;Deux figures ont posé plus de difficultés que cela. Deux listes largement recopiées donnent à Sa et Trukpen les figures inverses. J'ai failli publier cette erreur, parce que la vérification évidente passe dans les deux cas : de part et d'autre on obtient quatre figures palindromes et six paires par renversement.&lt;/p&gt;

&lt;p&gt;Ce qui tranche, ce sont les relations. Guda renversé donne Sa, et Ka renversé donne Trukpen. Intervertissez les deux et les deux relations se brisent simultanément. La version inversée est donc incohérente avec elle-même, ce qui se démontre sans avoir à décider quelle source est la plus fiable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ce qui n'y est délibérément pas
&lt;/h3&gt;

&lt;p&gt;Il n'y a aucun verset et aucune interprétation. Un signe revient avec sa figure, ses noms en fon et en yoruba, et ses relations structurelles, et rien sur ce qu'il signifie.&lt;/p&gt;

&lt;p&gt;C'est une ligne tracée volontairement. Le corpus interprétatif est immense, il s'enseigne différemment selon les lignées, et une bonne partie relève d'un savoir qui appartient aux initiés. Le comprimer dans un fichier JSON produirait quelque chose à la fois inexact et présomptueux.&lt;/p&gt;

&lt;p&gt;Une application qui a la légitimité de porter l'interprétation peut le faire, en s'appuyant sur l'index ou le nom que la bibliothèque fournit. Le rôle de la bibliothèque est de garantir que la structure en dessous est juste.&lt;/p&gt;

&lt;h3&gt;
  
  
  L'utiliser
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @catidegla/fadu cast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La direction la plus utile est la lecture d'un tirage réel plutôt que sa simulation, ce dont une application a réellement besoin :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readIkin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@catidegla/fadu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;readIkin&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;fon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 'Gbé Yèku'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Une passe qui ne laisse aucune noix, ou trois, est refusée plutôt que transformée en une figure que personne n'a tirée.&lt;/p&gt;

&lt;p&gt;69 tests, aucune dépendance, et les fichiers &lt;code&gt;data/du.json&lt;/code&gt; et &lt;code&gt;data/signs.json&lt;/code&gt; sont livrés pour que les données soient exploitables depuis n'importe quel langage. &lt;a href="https://github.com/catidegla/fadu" rel="noopener noreferrer"&gt;fadu&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Autant que j'aie pu chercher, il n'existait aucune bibliothèque comparable, dans aucun langage. Cette absence est la raison de son existence, et c'est aussi l'avertissement : il n'y a rien pour vérifier les figures sinon les sources, qui sont donc citées intégralement.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>afrique</category>
      <category>algorithmes</category>
    </item>
    <item>
      <title>Two published tables disagreed, and the structure settled it</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:22:56 +0000</pubDate>
      <link>https://dev.to/catidegla/two-published-tables-disagreed-and-the-structure-settled-it-4nmb</link>
      <guid>https://dev.to/catidegla/two-published-tables-disagreed-and-the-structure-settled-it-4nmb</guid>
      <description>&lt;p&gt;I was encoding the sixteen principal figures of Fa, the divination system of the Fon of Benin. Sixteen rows, four marks each. Trivial data entry.&lt;/p&gt;

&lt;p&gt;I nearly shipped two of them swapped, and the way I caught it is worth writing down, because the obvious check passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The disagreement
&lt;/h3&gt;

&lt;p&gt;A search summary handed me a table giving the figure &lt;code&gt;2212&lt;/code&gt; for Sa and &lt;code&gt;2111&lt;/code&gt; for Trukpen, where &lt;code&gt;1&lt;/code&gt; is a single stroke and &lt;code&gt;2&lt;/code&gt; is a double, read top to bottom.&lt;/p&gt;

&lt;p&gt;Another source gave the opposite: Sa is &lt;code&gt;2111&lt;/code&gt;, Trukpen is &lt;code&gt;2212&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two published tables, contradicting each other on exactly two of the sixteen. Everything else agreed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The check that did not help
&lt;/h3&gt;

&lt;p&gt;The sixteen figures have structure you can test. Reverse a figure top to bottom and you get another valid figure. Under that operation the set partitions into figures that read the same both ways, and pairs that map to each other.&lt;/p&gt;

&lt;p&gt;So: count them. If one arrangement produced a broken partition, that would settle it.&lt;/p&gt;

&lt;p&gt;Both arrangements give four palindromes and six reverse pairs. Four plus twelve equals sixteen, cleanly, either way.&lt;/p&gt;

&lt;p&gt;That is the shape of this kind of error. The obvious invariant is a property of the figures, and swapping two names does not change the figures. It only changes which name sits beside which pattern, and the counting test cannot see names at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The check that did
&lt;/h3&gt;

&lt;p&gt;Reversal also says which figure each one reverses into, which is a claim about pairs of names and not about the set.&lt;/p&gt;

&lt;p&gt;Under the second arrangement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Guda reversed is Sa&lt;/li&gt;
&lt;li&gt;Ka reversed is Trukpen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the first arrangement, both of those break at once. Guda would reverse into Trukpen and Ka into Sa, which contradicts every table's ordering of those names.&lt;/p&gt;

&lt;p&gt;So the swapped version is inconsistent with itself, and you can demonstrate that without appealing to any authority about which source is more trustworthy.&lt;/p&gt;

&lt;p&gt;Then I went and read the primary source properly. Wikipedia's article on Ifá prints two tables, one Yoruba and one for the West African Afa-du set, and they agree with each other and with the second arrangement. &lt;code&gt;Ọ̀sá&lt;/code&gt; is &lt;code&gt;2111&lt;/code&gt;. &lt;code&gt;Òtúúrúpọ̀n&lt;/code&gt; is &lt;code&gt;2212&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is now a test named after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sa and Trukpen are not swapped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;du&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2111&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;du&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Trukpen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2212&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;du&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Guda&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;fon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;du&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ka&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;fon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Trukpen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two lines are the ones that matter. The first two would pass if I had transcribed the same mistake into both the table and the test.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I took from it
&lt;/h3&gt;

&lt;p&gt;A consistency check that passes on both candidates is not a check. Counting palindromes felt like verification and verified nothing about the question I had. Ask of any invariant you rely on which wrong answers it would still accept.&lt;/p&gt;

&lt;p&gt;Relations between elements are stronger evidence than properties of the set. The cardinality was identical under both arrangements and the pairing was not. Checks that name specific elements catch attribution errors that aggregate checks cannot.&lt;/p&gt;

&lt;p&gt;Search summaries are not sources. The wrong table came from a confident-sounding summary, and fetching the actual article took about a minute. That is the second time in a month reading the primary source caught something a summary had backwards.&lt;/p&gt;

&lt;p&gt;Say where the uncertainty remains. The ordering of the sixteen genuinely varies by region, and I could not resolve that the same way, because unlike the figures there is no internal structure that makes one ordering self-consistent and another not. So the library documents that &lt;code&gt;rank&lt;/code&gt; is an index and not a claim about seniority, and the seniority ordering of the 256 is not encoded at all.&lt;/p&gt;

&lt;p&gt;The library is &lt;a href="https://github.com/catidegla/fadu" rel="noopener noreferrer"&gt;fadu&lt;/a&gt;: the sixteen dù, the 256 signs, both casting mechanics, and no interpretation at all. 69 tests, zero dependencies, and every source cited.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>datastructures</category>
      <category>culture</category>
      <category>testing</category>
    </item>
    <item>
      <title>A 256-state binary system from Benin, and what it takes to encode it correctly</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:16:59 +0000</pubDate>
      <link>https://dev.to/catidegla/a-256-state-binary-system-from-benin-and-what-it-takes-to-encode-it-correctly-2cbg</link>
      <guid>https://dev.to/catidegla/a-256-state-binary-system-from-benin-and-what-it-takes-to-encode-it-correctly-2cbg</guid>
      <description>&lt;p&gt;Fa is the divination system of the Fon people of Benin, where I am from. The Yoruba practise it as Ifá, and the two share their figures exactly.&lt;/p&gt;

&lt;p&gt;The structure is binary, and I mean that literally rather than as a metaphor.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;p&gt;A figure is two columns. Each column has four positions. Each position carries either one stroke or two.&lt;/p&gt;

&lt;p&gt;Four positions with two states each gives sixteen possible columns. Two columns side by side gives &lt;strong&gt;2⁸ = 256&lt;/strong&gt; signs. Those sixteen columns are the sixteen principal dù, and the 256 pairs are the complete space.&lt;/p&gt;

&lt;p&gt;Two instruments produce them. Sixteen palm nuts, called ikin, worked in eight passes. Or a chain of eight pods, opele in Yoruba and agumaga in Fon, thrown once so all eight land together. Either way the right-hand column is produced first, and that decides the name of the resulting sign.&lt;/p&gt;

&lt;p&gt;There is a rule in the palm nut method that is inverted from what everyone guesses, including me before I checked:&lt;/p&gt;

&lt;p&gt;One nut left in the hand means a double stroke. Two nuts left means a single.&lt;/p&gt;

&lt;p&gt;It is the most frequently miscopied detail in casual descriptions of the procedure, so the test suite asserts it in both directions where a refactor cannot quietly flip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The relations are the interesting part
&lt;/h3&gt;

&lt;p&gt;Once the sixteen figures are written down as bit patterns, structure falls out that is worth looking at as a programmer.&lt;/p&gt;

&lt;p&gt;Reversal: turn a figure upside down. Four of the sixteen read the same either way: Gbé, Yèku, Woli and Di. The other twelve pair off into six couples. Four plus twelve, and the reversal operation is its own inverse.&lt;/p&gt;

&lt;p&gt;Complement: swap every single stroke for a double and vice versa. This pairs all sixteen into eight couples with no fixed points at all, which follows from four positions never being able to all differ from themselves.&lt;/p&gt;

&lt;p&gt;Those two operations partition the same sixteen elements in two different ways, and the partitions do not agree. That turns out to be load bearing, as the next section shows.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hard part was the data
&lt;/h3&gt;

&lt;p&gt;I expected the code to be the work. It is a few hundred lines of bit manipulation and the tests write themselves: assert that sixteen figures cover all sixteen four-position states exactly once, assert 256 distinct signs, assert every name parses back to the sign it came from.&lt;/p&gt;

&lt;p&gt;The hard part was establishing what the figures are.&lt;/p&gt;

&lt;p&gt;Sources disagree. Not on the mathematics, which nobody disputes, but on which name attaches to which figure and in what order they are listed. Ordering varies by region: the sequence I use is given by fongbebenin.com for the Fon dù and by ileifa.org for the Yoruba odù, and those two agree position for position, but at least one published Beninese list moves Ka to eleventh. Wikipedia's own table carries an editorial note saying the order varies.&lt;/p&gt;

&lt;p&gt;So the library treats &lt;code&gt;rank&lt;/code&gt; as a stable handle for indexing and says explicitly that it is not a claim about seniority. The seniority ordering of the 256 is lineage-specific and is not encoded at all.&lt;/p&gt;

&lt;p&gt;Two figures were harder than that, and they get their own article.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is deliberately not in it
&lt;/h3&gt;

&lt;p&gt;There are no verses and no readings. A sign comes back with its figure, its names in Fon and Yoruba, and its structural relations, and nothing about what it means.&lt;/p&gt;

&lt;p&gt;That line is drawn on purpose. The interpretive corpus is enormous, it is taught differently by different lineages, and a good deal of it is knowledge that belongs to initiates. Compressing it into a JSON file would produce something both inaccurate and presumptuous.&lt;/p&gt;

&lt;p&gt;An application that has the standing to carry interpretation can carry it, keyed on the index or the name this gives it. The library's job is to make sure the structure underneath is right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @catidegla/fadu cast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Loso Fu
  Ìrosùn Òfún

  │ │    │
   │     │
  │ │   │ │
   │    │ │

  1122|2121   mixed, index 79 of 255
  pods, in order: o o c c c o c o
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The more useful direction is reading a physical throw rather than simulating one, which is what an application actually needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readIkin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@catidegla/fadu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;readIkin&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;fon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 'Gbé Yèku'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pass that leaves no nuts, or three, is refused rather than being turned into a figure nobody cast.&lt;/p&gt;

&lt;p&gt;Casting draws from the platform CSPRNG instead of &lt;code&gt;Math.random&lt;/code&gt;, which costs nothing and seemed like the minimum for the subject matter.&lt;/p&gt;

&lt;p&gt;69 tests, zero dependencies, and &lt;code&gt;data/du.json&lt;/code&gt; and &lt;code&gt;data/signs.json&lt;/code&gt; ship so the data is usable from any language. &lt;a href="https://github.com/catidegla/fadu" rel="noopener noreferrer"&gt;fadu&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;As far as I can find, there was no comparable open source library in any language. That absence is why it exists, and it is also the warning: there is nothing to check the figures against except the sources, which is why they are cited in full.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>culture</category>
      <category>datastructures</category>
    </item>
    <item>
      <title>Exposer vos modèles Eloquent à un agent IA sans lui donner votre base</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:16:13 +0000</pubDate>
      <link>https://dev.to/catidegla/exposer-vos-modeles-eloquent-a-un-agent-ia-sans-lui-donner-votre-base-2d37</link>
      <guid>https://dev.to/catidegla/exposer-vos-modeles-eloquent-a-un-agent-ia-sans-lui-donner-votre-base-2d37</guid>
      <description>&lt;p&gt;Voici le premier outil MCP que presque tout le monde écrit :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Il fonctionne, et il contient trois failles de sécurité distinctes qui ne sont pas visibles dans le code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Un : il renvoie toutes les colonnes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Ticket::find()&lt;/code&gt; vous donne le modèle. Sérialiser le modèle donne tous les attributs de la ligne.&lt;/p&gt;

&lt;p&gt;Cela inclut &lt;code&gt;internal_notes&lt;/code&gt;. Cela inclut le &lt;code&gt;card_last_four&lt;/code&gt; ajouté pour un écran de support. Cela inclut la colonne ajoutée la semaine dernière pour une fonctionnalité pas encore livrée.&lt;/p&gt;

&lt;p&gt;C'est la plus discrète des trois parce que rien n'a l'air anormal. Vos vues Blade affichent quatre champs, donc vous pensez au modèle comme ayant quatre champs. L'agent les reçoit tous, et le modèle explique volontiers leur contenu à qui le demande.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deux : il ignore qui pose la question
&lt;/h3&gt;

&lt;p&gt;Il n'y a aucun utilisateur dans ce code. La requête n'est pas filtrée, donc l'outil récupère n'importe quel ticket par identifiant, pour quiconque peut l'appeler.&lt;/p&gt;

&lt;p&gt;Ajouter &lt;code&gt;-&amp;gt;where('user_id', auth()-&amp;gt;id())&lt;/code&gt; corrige le cas immédiat et en crée un plus subtil : le filtre vit désormais dans l'outil et non dans vos policies. Quand quelqu'un ajoutera un deuxième outil, une relation ou un scope, il faudra s'en souvenir. On ne s'en souviendra pas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trois : rien n'empêche l'énumération
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;find($id)&lt;/code&gt; avec un identifiant auto-incrémenté est une invitation. Un agent qui peut appeler l'outil peut l'appeler avec 1, 2, 3, et continuer. Même une fois la policy ajoutée, la forme de l'échec fuit : un enregistrement inexistant et un enregistrement interdit répondent en général différemment, et cette différence cartographie les identifiants existants.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ce que fait laravel/mcp, et ce qu'il ne fait pas
&lt;/h3&gt;

&lt;p&gt;Rien de tout cela n'est une critique de &lt;a href="https://github.com/laravel/mcp" rel="noopener noreferrer"&gt;&lt;code&gt;laravel/mcp&lt;/code&gt;&lt;/a&gt;. Il compte 34,5 millions d'installations, il est officiel, et il fait très bien son travail. Son travail, c'est le protocole.&lt;/p&gt;

&lt;p&gt;Ce qu'il ne fait délibérément pas, c'est décider ce que votre outil a le droit de renvoyer. C'est une préoccupation applicative, et il serait malvenu qu'un package de protocole la devine.&lt;/p&gt;

&lt;p&gt;C'est aussi une préoccupation dont la forme est identique dans toutes les applications, ce qui en fait un bon candidat pour un package plutôt que quelque chose que chaque équipe réinvente un vendredi à 17 h.&lt;/p&gt;

&lt;h3&gt;
  
  
  Déclarer l'exposition
&lt;/h3&gt;

&lt;p&gt;Ce que j'ai retenu, c'est un attribut sur le modèle :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;AgentResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;searchable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;filterable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;relations&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'comments'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;maxResults&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;C'est toute la configuration, et chaque argument ferme une des trois failles.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fields&lt;/code&gt; est une liste blanche. La projection travaille depuis cette liste seule et n'inspecte jamais le modèle pour décider quoi inclure, donc ajouter une colonne ne peut pas élargir l'exposition.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxResults&lt;/code&gt; est un plafond appliqué par-dessus ce que demande l'agent, avec un second plafond global en configuration.&lt;/p&gt;

&lt;p&gt;L'autorisation passe par vos policies existantes, enregistrement par enregistrement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ce que je défends le plus
&lt;/h3&gt;

&lt;p&gt;Chacun de ces mécanismes échoue en se fermant.&lt;/p&gt;

&lt;p&gt;Un modèle sans policy lève une exception. Oublier d'écrire une policy est l'erreur la plus probable de tout le processus, donc le comportement par défaut doit être le refus.&lt;/p&gt;

&lt;p&gt;Un &lt;code&gt;get&lt;/code&gt; refusé est indiscernable d'un enregistrement inexistant. Les deux renvoient null, donc l'outil ne peut pas servir à découvrir quels identifiants existent.&lt;/p&gt;

&lt;p&gt;Les lignes refusées dans une liste sont signalées, pas supprimées. Si un outil retire silencieusement des lignes, l'agent croit la liste complète, puis répond avec assurance à une question à son sujet. Le compte revient donc :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rows'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'denied'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'truncated'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ne pas écrire les outils du tout
&lt;/h3&gt;

&lt;p&gt;Puisque l'attribut décrit déjà tout ce dont un outil a besoin, les outils sont générés :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan agent-kit:mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Une classe d'outil &lt;code&gt;laravel/mcp&lt;/code&gt; par capacité déclarée, avec le schéma d'entrée dérivé du même attribut. Le handler généré délègue à la ressource et ne fait rien d'autre, ce que le fichier généré indique en en-tête : toutes les garanties vivent dans la ressource, et tout ce qu'on ajoute à un outil s'exécute en dehors d'elles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require catidegla/laravel-agent-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;58 tests sur PHP 8.2, 8.3 et 8.4, dont un qui charge une classe générée et la fait passer par le sérialiseur de &lt;code&gt;laravel/mcp&lt;/code&gt;. &lt;a href="https://github.com/catidegla/laravel-agent-kit" rel="noopener noreferrer"&gt;Le dépôt est ici&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ia</category>
      <category>securite</category>
    </item>
    <item>
      <title>Letting an AI agent follow a relation without widening what it can see</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:10:16 +0000</pubDate>
      <link>https://dev.to/catidegla/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see-3o8l</link>
      <guid>https://dev.to/catidegla/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see-3o8l</guid>
      <description>&lt;p&gt;Once an agent can read a model, it wants the neighbours. Show me the ticket, and its comments, and who wrote them.&lt;/p&gt;

&lt;p&gt;The naive implementation is &lt;code&gt;-&amp;gt;with($request-&amp;gt;get('include'))&lt;/code&gt;, which is a full database export for anyone who asks nicely. Eloquent will follow any relation name you hand it, and from &lt;code&gt;ticket&lt;/code&gt; you can reach &lt;code&gt;user&lt;/code&gt;, and from &lt;code&gt;user&lt;/code&gt; you can reach &lt;code&gt;orders&lt;/code&gt;, and from there everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule that makes it safe
&lt;/h3&gt;

&lt;p&gt;A relation must not widen exposure.&lt;/p&gt;

&lt;p&gt;Expanding &lt;code&gt;comments&lt;/code&gt; returns exactly what &lt;code&gt;Comment&lt;/code&gt; declares in its own attribute, checked against &lt;code&gt;Comment&lt;/code&gt;'s own policy, for the same signed-in user. Not the parent's field list, not the parent's policy, and not a special relation mode with looser rules.&lt;/p&gt;

&lt;p&gt;So a relation composes two exposures that already existed and never creates a third. Whatever an agent can see through &lt;code&gt;ticket.comments&lt;/code&gt;, it could already have seen by asking for comments directly, given a tool for that.&lt;/p&gt;

&lt;p&gt;Everything else follows from holding that line.&lt;/p&gt;

&lt;h3&gt;
  
  
  A relation cannot reach an unexposed model
&lt;/h3&gt;

&lt;p&gt;If the target is not a registered resource, the call is refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ticket declares "notes" as traversable but PrivateNote is not a registered
resource. A relation is not a way to reach a model that was never exposed:
the target carries its own field list and its own policy, and without them
there is nothing to enforce.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This closes the obvious back door. Without it, &lt;code&gt;relations: ['notes']&lt;/code&gt; on one model quietly publishes a table nobody put in the exposure list, and the pull request that did it shows one word.&lt;/p&gt;

&lt;p&gt;There is a neat consequence. To make a model reachable through relations only, without a tool that can enumerate it, register it with no abilities of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[AgentResource(fields: ['id', 'name'], abilities: [])]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registered, so exposure stays visible in the one config file where it belongs. No tools generated, so nothing can list your users. Reachable as &lt;code&gt;ticket.author&lt;/code&gt;, returning two fields, checked against the user policy.&lt;/p&gt;

&lt;p&gt;That falls out of composing two existing ideas, with no "relation-only" concept invented for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  One level, never two
&lt;/h3&gt;

&lt;p&gt;An expanded record does not itself expand relations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$resource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;include&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'comments'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comments come back. Their relations do not. If the agent needs the next hop it makes another tool call, which is separately authorized and separately recorded in the audit trail.&lt;/p&gt;

&lt;p&gt;That is a real constraint and I think it is the right one. Depth limits are the usual answer and they are a slider people turn up. Making each hop a separate call keeps each hop visible, and keeps "what did the agent read" answerable instead of turning it into one enormous nested payload.&lt;/p&gt;

&lt;p&gt;Dot notation asking for two hops at once is refused for the same reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two failure modes people forget
&lt;/h3&gt;

&lt;p&gt;A to-many relation needs its own ceiling. A ticket with 4,000 comments is a full table dump reached through a relation. The cap comes from the target's declared maximum, not the parent's, and truncation is reported instead of silently trimmed.&lt;/p&gt;

&lt;p&gt;A to-one the viewer cannot see returns null. There is a test for this: Alice reads her own ticket and asks for &lt;code&gt;escalatedTo&lt;/code&gt;, which points at Bob. &lt;code&gt;author&lt;/code&gt; resolves because she may see herself. &lt;code&gt;escalatedTo&lt;/code&gt; comes back null. A declared relation does not make its contents hers to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  The unglamorous part: N+1
&lt;/h3&gt;

&lt;p&gt;Authorizing per record means a policy check per record, which is unavoidable and correct. Issuing a query per record is not.&lt;/p&gt;

&lt;p&gt;Relations are eager loaded, so expanding across a page of results costs one extra query per relation instead of one per row. There is a test asserting the query count, so a future refactor that drops the eager load fails instead of quietly getting slow.&lt;/p&gt;

&lt;p&gt;The names are validated against the declared list before they reach &lt;code&gt;with()&lt;/code&gt;, so nothing arbitrary is passed to Eloquent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Catching it before production
&lt;/h3&gt;

&lt;p&gt;Bad relation configuration is caught at deploy time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Registry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reports a relation declared with no method behind it, and a relation pointing at a model that is not registered. Run it in a test and a misconfiguration fails the build.&lt;/p&gt;

&lt;p&gt;The audit trail records the ids reached through each relation, because "what did the agent read" gives the wrong answer in exactly the interesting case if it stops at the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;agent 41 ticket.get -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ok &lt;span class="o"&gt;(&lt;/span&gt;1 returned, 0 denied&lt;span class="o"&gt;)&lt;/span&gt; via comments[2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/catidegla/laravel-agent-kit" rel="noopener noreferrer"&gt;catidegla/laravel-agent-kit&lt;/a&gt;. 58 tests, PHP 8.2 to 8.4.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>ai</category>
      <category>security</category>
      <category>eloquent</category>
    </item>
    <item>
      <title>An AI agent audit trail that is not a second copy of your database</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:09:30 +0000</pubDate>
      <link>https://dev.to/catidegla/an-ai-agent-audit-trail-that-is-not-a-second-copy-of-your-database-18if</link>
      <guid>https://dev.to/catidegla/an-ai-agent-audit-trail-that-is-not-a-second-copy-of-your-database-18if</guid>
      <description>&lt;p&gt;After anything goes wrong with an AI agent, the first question is always the same: what did it actually read?&lt;/p&gt;

&lt;p&gt;You cannot answer that later if nothing was written down at the time. So you add logging, and the obvious implementation is to log what the tool returned.&lt;/p&gt;

&lt;p&gt;That is where it goes wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The log becomes the softer target
&lt;/h3&gt;

&lt;p&gt;An audit trail that stores the rows holds, after a month of operation, a copy of every record any agent has ever read. Reconstructed from tool responses, sitting in a table or a log aggregator, and nobody has written a policy for it.&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;tickets&lt;/code&gt; table has a policy. Every read goes through it, per record. The audit log has whoever has log access, which is usually a much longer list including anyone who can search your observability platform.&lt;/p&gt;

&lt;p&gt;You built a careful exposure layer and then created an unguarded mirror of everything that passed through it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Identifiers and field names, never values
&lt;/h3&gt;

&lt;p&gt;The design I settled on records what was reached, not what it contained:&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;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-09T14:02:11+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"41"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&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;span class="nl"&gt;"ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"denied"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"truncated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;From that you can answer the question completely. Which records were exposed, to whom, through which tool, when, and which fields of them. To see the values you go back and read those ids from the source, where the policy still applies and the read is itself audited.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fields&lt;/code&gt; is the piece people leave out. Without it you know record 7 was read but not whether that included &lt;code&gt;internal_notes&lt;/code&gt;, and the field list changes when the attribute changes.&lt;/p&gt;

&lt;p&gt;There is one deliberate exception. &lt;code&gt;arguments&lt;/code&gt; records what the agent supplied, including a search term, because "what was it looking for" is half of any incident. That is the agent's input, and it should be read back as untrusted text since a prompt can put anything there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The distinction the agent must not see
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;get&lt;/code&gt; returns null for both a missing record and a denied one, so ids cannot be enumerated.&lt;/p&gt;

&lt;p&gt;The operator needs to know which it was. A run of denials is somebody probing; a run of misses is a broken integration. Those need different responses, and the difference is invisible if both are logged the same way.&lt;/p&gt;

&lt;p&gt;So the trail records them apart while the agent still cannot tell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$model&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AuditEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AuditEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DENIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical to the caller, different in the record the caller never sees. That asymmetry is the point of having an audit log at all, and it was not expressible before, because the old code could not tell the two cases apart internally either.&lt;/p&gt;

&lt;p&gt;Refused calls are recorded too. One filter on an unexposed field is a typo. A run of them is probing, and that pattern only exists if failures are written down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failing closed, including here
&lt;/h3&gt;

&lt;p&gt;The uncomfortable decision: by default a sink that refuses takes the read down with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AuditFailedException: The audit sink refused to record ticket.get, so the
result was withheld rather than served unrecorded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the least popular thing in the package and I will make the argument for it. A trail with silent gaps cannot answer the question it exists for, and the moment a gap opens is exactly the moment it is worth the most to whoever caused it. Serving data you cannot account for is the failure the whole package exists to prevent, so the audit trail should not be the one component that degrades quietly.&lt;/p&gt;

&lt;p&gt;It is configurable, because availability is a legitimate thing to want more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'audit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'strict'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real trade and it is yours to make. What matters is that it is a decision and not an accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it goes
&lt;/h3&gt;

&lt;p&gt;The default writes to a Laravel log channel, so it works on a fresh install with no migration and no table to forget. That is a starting point. Log files rotate, and "what did the agent read three months ago" is eventually a query.&lt;/p&gt;

&lt;p&gt;So the sink is an interface. Bind your own and the package uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditSink&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;YourDatabaseSink&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also an &lt;code&gt;ArraySink&lt;/code&gt; shipped for your own test suite, because the useful thing to assert in an application test is that a tool call produced the record you expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one line version
&lt;/h3&gt;

&lt;p&gt;Log which records were exposed and to whom, and not what was in them. The source of truth already has a policy; your audit log does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/catidegla/laravel-agent-kit" rel="noopener noreferrer"&gt;catidegla/laravel-agent-kit&lt;/a&gt;, and there is a test asserting that no field value ever reaches an audit record.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Your Laravel MCP tool returns every column, and three lines of code hide it</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:03:33 +0000</pubDate>
      <link>https://dev.to/catidegla/your-laravel-mcp-tool-returns-every-column-and-three-lines-of-code-hide-it-2l52</link>
      <guid>https://dev.to/catidegla/your-laravel-mcp-tool-returns-every-column-and-three-lines-of-code-hide-it-2l52</guid>
      <description>&lt;p&gt;This is the first MCP tool nearly everyone writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, and it contains three separate security failures that are not visible in the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  One: it returns every column
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Ticket::find()&lt;/code&gt; gives you the model. Serialising the model gives you every attribute on the row.&lt;/p&gt;

&lt;p&gt;That includes &lt;code&gt;internal_notes&lt;/code&gt;. It includes the &lt;code&gt;card_last_four&lt;/code&gt; somebody added for a support screen. It includes whatever column was added last week for a feature that has not shipped, and the &lt;code&gt;admin_flag&lt;/code&gt; that nothing in your UI renders.&lt;/p&gt;

&lt;p&gt;This is the quietest of the three because nothing looks wrong. Your Blade templates render four fields, so you think of the model as having four fields. The agent gets all of them, and the model happily explains their contents to whoever asked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two: it has no idea who is asking
&lt;/h3&gt;

&lt;p&gt;There is no user in that code. The query is unscoped, so the tool will fetch any ticket by id, for anyone who can reach the tool.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;-&amp;gt;where('user_id', auth()-&amp;gt;id())&lt;/code&gt; fixes the immediate case and creates a subtler one. The filter now lives in the tool instead of in your policies, so when someone adds a second tool, or a relation, or a scope, the rule has to be remembered again. It will not be.&lt;/p&gt;

&lt;p&gt;The check belongs where every other authorization check in the application lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three: nothing stops enumeration
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;find($id)&lt;/code&gt; with an incrementing id is an invitation. An agent that can call the tool can call it with 1, 2, 3, and keep going. Even with the policy check added, the shape of the failure leaks: a missing record and a forbidden one usually answer differently, and that difference maps out which ids exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  What laravel/mcp is and is not
&lt;/h3&gt;

&lt;p&gt;None of this is a criticism of &lt;a href="https://github.com/laravel/mcp" rel="noopener noreferrer"&gt;&lt;code&gt;laravel/mcp&lt;/code&gt;&lt;/a&gt;. It has 34.8 million installs, it is official, and it does its job well. Its job is the protocol: define a tool, let an agent call it, handle transport and schemas.&lt;/p&gt;

&lt;p&gt;What it deliberately does not do is decide what your tool is allowed to hand back. That is an application concern, and it would be wrong for a protocol package to guess at it.&lt;/p&gt;

&lt;p&gt;It is also a concern with a consistent shape across every application, which makes it a good candidate for a package instead of something each team reinvents at 4pm on a Friday.&lt;/p&gt;

&lt;h3&gt;
  
  
  Declaring the exposure instead
&lt;/h3&gt;

&lt;p&gt;What I ended up with is an attribute on the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;AgentResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;searchable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;filterable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Support tickets belonging to the signed in user.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maxResults&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole configuration, and each argument closes one of the three failures.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fields&lt;/code&gt; is an allowlist. Projection works from that list only and never inspects the model to decide what to include, so adding a column cannot widen exposure. Anything that is not a scalar after casting is refused, because a nested array arriving from a cast would return contents nobody declared.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxResults&lt;/code&gt; is a ceiling applied on top of whatever the agent asks for, with a second global ceiling in config so no single attribute can raise the limit for the whole application.&lt;/p&gt;

&lt;p&gt;Authorization runs through your existing policies, per record, not once per query.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part I would defend hardest
&lt;/h3&gt;

&lt;p&gt;Every one of these fails closed.&lt;/p&gt;

&lt;p&gt;A model with no policy throws. Forgetting to write a policy is the most likely mistake in the whole flow, so the default has to be refusal.&lt;/p&gt;

&lt;p&gt;A denied &lt;code&gt;get&lt;/code&gt; is indistinguishable from a missing one. Both return null, so the tool cannot be used to discover which ids exist.&lt;/p&gt;

&lt;p&gt;Denied rows in a list are reported, not dropped. This one took the longest to get right. A tool that silently removes rows the caller cannot see leaves the agent believing the list is complete, and it then answers questions about it with confidence. So the count comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'rows'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'denied'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'truncated'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Truncation is reported for the same reason. The query fetches one row over the limit so the response can say whether there were more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exposure is a list, not a scan
&lt;/h3&gt;

&lt;p&gt;One deliberate non-feature: the package does not scan your codebase for the attribute. Exposed models are listed explicitly in config.&lt;/p&gt;

&lt;p&gt;With a scan, adding an attribute anywhere publishes a table, and the reviewer of that pull request sees one line in a model file. With a list, they see a change to the application's exposed surface, which is what it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not writing the tools at all
&lt;/h3&gt;

&lt;p&gt;The attribute already describes everything a tool needs, so the tools are generated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan agent-kit:mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;laravel/mcp&lt;/code&gt; tool class per declared ability, with the input schema derived from the same attribute, so the agent sees which fields are filterable and what ceiling it will be held to. The generated handler delegates to the resource and does nothing else, which the generated file says at the top, because every guarantee lives in the resource and anything added to a tool runs outside all of them.&lt;/p&gt;

&lt;p&gt;It refuses to generate from an exposure that fails verification, and it will not overwrite an existing file without &lt;code&gt;--force&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require catidegla/laravel-agent-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;58 tests across PHP 8.2, 8.3 and 8.4, including one that loads a generated tool class and runs it through &lt;code&gt;laravel/mcp&lt;/code&gt;'s own serialiser. &lt;a href="https://github.com/catidegla/laravel-agent-kit" rel="noopener noreferrer"&gt;Repo here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Zero is singular in French, and five other translation bugs that never throw</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 17:02:47 +0000</pubDate>
      <link>https://dev.to/catidegla/zero-is-singular-in-french-and-five-other-translation-bugs-that-never-throw-2gpi</link>
      <guid>https://dev.to/catidegla/zero-is-singular-in-french-and-five-other-translation-bugs-that-never-throw-2gpi</guid>
      <description>&lt;p&gt;Translation bugs are quiet. Nothing throws, tests pass, and the interface says something slightly wrong to half your users. You find out when one of them tells you, if they bother.&lt;/p&gt;

&lt;p&gt;I ship bilingual software in French and English. These are the six that keep happening.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The placeholder got translated
&lt;/h3&gt;

&lt;p&gt;The worst one, because the placeholder name looks like a word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// en&lt;/span&gt;
&lt;span class="s1"&gt;'welcome'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Hello :name, you have :count messages'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;// fr, translated by someone being thorough&lt;/span&gt;
&lt;span class="s1"&gt;'welcome'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bonjour :nom, vous avez :count messages'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:nom&lt;/code&gt; is not a placeholder. Nothing substitutes it, and the user sees the literal characters &lt;code&gt;:nom&lt;/code&gt; in the middle of a sentence.&lt;/p&gt;

&lt;p&gt;Easy mistake, easy to catch mechanically: compare the placeholder sets on both sides of every key and fail if they differ. Same rule for ICU and next-intl, where &lt;code&gt;{name}&lt;/code&gt; stays &lt;code&gt;{name}&lt;/code&gt; and only the surrounding text changes.&lt;/p&gt;

&lt;p&gt;This one belongs in CI. It has no false positives.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Zero is plural in English and singular in French
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;English&lt;/th&gt;
&lt;th&gt;French&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;0 item*&lt;em&gt;s&lt;/em&gt;*&lt;/td&gt;
&lt;td&gt;0 article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 item&lt;/td&gt;
&lt;td&gt;1 article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2 item*&lt;em&gt;s&lt;/em&gt;*&lt;/td&gt;
&lt;td&gt;2 article*&lt;em&gt;s&lt;/em&gt;*&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;English treats zero as plural. French treats it as singular. Every French interface saying "0 articles" got there this way.&lt;/p&gt;

&lt;p&gt;Laravel's &lt;code&gt;MessageSelector&lt;/code&gt; already implements the French rule, so &lt;code&gt;trans_choice&lt;/code&gt; picks correctly with no work from you. The bug appears when someone builds the string by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: the English rule, applied to French.&lt;/span&gt;
&lt;span class="nv"&gt;$label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'article'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'articles'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Right.&lt;/span&gt;
&lt;span class="nf"&gt;trans_choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'messages.articles'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lang/fr/messages.php&lt;/span&gt;
&lt;span class="s1"&gt;'articles'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'article|articles'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generalises past French. A ternary on a count encodes one language's rule into your control flow, where no translation file can reach it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. ICU categories do not map across languages
&lt;/h3&gt;

&lt;p&gt;On next-intl or formatjs, plural categories are per language and copying the English structure does not work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fluent"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="no"&gt;count&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;plural, =0 &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;A&lt;/span&gt;&lt;span class="no"&gt;ucun&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;article&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; one &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;article&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; other &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arabic has six plural categories. Russian and Polish have three. Missing categories make the formatter fall back to &lt;code&gt;other&lt;/code&gt; silently, producing grammatically wrong output with no error anywhere.&lt;/p&gt;

&lt;p&gt;If you target any of those languages, read the CLDR plural rules for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. An empty string is worse than a missing key
&lt;/h3&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;"nav.settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&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;A missing key usually triggers fallback to the source locale, so the user sees English. Readable, if not ideal.&lt;/p&gt;

&lt;p&gt;An empty string often does not, because fallback logic checks whether the key exists. The user gets a blank button.&lt;/p&gt;

&lt;p&gt;So a parity check has to treat empty values as failures instead of as present keys. People forget this case precisely because the key is technically there.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. French typography is not English typography with accents
&lt;/h3&gt;

&lt;p&gt;Native speakers notice these instantly and reviewers miss all of them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Narrow non-breaking space before &lt;code&gt;; : ! ?&lt;/code&gt; and inside &lt;code&gt;« »&lt;/code&gt;. It is &lt;code&gt;Bonjour !&lt;/code&gt;, not &lt;code&gt;Bonjour!&lt;/code&gt;. The character is U+202F, with U+00A0 as the widely supported fallback. With a plain space the punctuation can wrap to the next line by itself.&lt;/li&gt;
&lt;li&gt;Guillemets for quotations. &lt;code&gt;« comme ceci »&lt;/code&gt;, not &lt;code&gt;"like this"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decimal comma, space for thousands. &lt;code&gt;1 234,56&lt;/code&gt;, not &lt;code&gt;1,234.56&lt;/code&gt;. Use &lt;code&gt;Intl.NumberFormat('fr-FR')&lt;/code&gt; or PHP's &lt;code&gt;NumberFormatter&lt;/code&gt;, never string manipulation.&lt;/li&gt;
&lt;li&gt;Lowercase months and weekdays. &lt;code&gt;7 septembre 2026&lt;/code&gt;, not &lt;code&gt;7 Septembre 2026&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Currency after the amount. &lt;code&gt;12,50 €&lt;/code&gt;, not &lt;code&gt;€12.50&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sentence case in titles. &lt;code&gt;Paramètres du compte&lt;/code&gt;, not &lt;code&gt;Paramètres Du Compte&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A key-by-key diff catches none of these, so a checker is necessary and not sufficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The string that was never extracted
&lt;/h3&gt;

&lt;p&gt;Keys present in both locales is half the job. A hardcoded string in a template is never missing from a locale file, because it was never in one.&lt;/p&gt;

&lt;p&gt;Parity checks compare files against each other, and a hardcoded string is in neither.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to automate and what to review
&lt;/h3&gt;

&lt;p&gt;Split them by whether a machine can be certain.&lt;/p&gt;

&lt;p&gt;Mechanical, belongs in CI, fails the build: missing keys, empty values, placeholder mismatches, plural branch count mismatches. Each is a definite defect with no legitimate reading.&lt;/p&gt;

&lt;p&gt;Warns but does not block: extra keys in the target, and values identical across locales. Sometimes a string genuinely is the same in both languages, and failing the build on "Email" being spelled the same way in French trains people to disable the check.&lt;/p&gt;

&lt;p&gt;That split matters more than the checks themselves. A parity checker that fails on things which are sometimes fine gets switched off, taking the ones that are never fine with it.&lt;/p&gt;

&lt;p&gt;I packaged this as an agent skill rather than only a script, because the second half of the list is judgement rather than comparison. The checker handles the mechanical part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node skills/i18n-parity/scripts/check-parity.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It auto-detects Laravel &lt;code&gt;lang/{locale}/*.php&lt;/code&gt;, Laravel JSON, next-intl &lt;code&gt;messages/{locale}.json&lt;/code&gt; and react-i18next &lt;code&gt;locales/{locale}/*.json&lt;/code&gt;, exits 1 on anything a user would see, and warns on the rest.&lt;/p&gt;

&lt;p&gt;The skill then carries the rules a diff cannot check, so an agent reviewing a translation pull request knows that a ternary on a count is suspect and that &lt;code&gt;Bonjour!&lt;/code&gt; is missing a space.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx github:catidegla/skillbelt add i18n-parity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/catidegla/skillbelt" rel="noopener noreferrer"&gt;skillbelt&lt;/a&gt; also carries Laravel and Next.js security review skills and a secrets audit, and installs into Claude Code, Codex, Cursor, Gemini CLI and Antigravity, which all read the same &lt;code&gt;SKILL.md&lt;/code&gt; format and disagree about which directory it lives in.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>laravel</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What nDCG sees that precision and recall miss</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:56:50 +0000</pubDate>
      <link>https://dev.to/catidegla/what-ndcg-sees-that-precision-and-recall-miss-2g8m</link>
      <guid>https://dev.to/catidegla/what-ndcg-sees-that-precision-and-recall-miss-2g8m</guid>
      <description>&lt;p&gt;Two retrieval runs for the same query. Relevant document is &lt;code&gt;D&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run A:  [D, x, x, x, x, x, x, x]
Run B:  [x, x, x, x, x, x, x, D]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Precision@8 is identical: one relevant document out of eight, 0.125 both times. Recall@8 is identical: you found the one that existed, 1.0 both times. Hit rate is 1.0 both times.&lt;/p&gt;

&lt;p&gt;Every set-based metric says these runs are the same. For a RAG system they are not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why position is most of the quality
&lt;/h3&gt;

&lt;p&gt;Retrieval feeds a context window, and a context window is ordered and finite.&lt;/p&gt;

&lt;p&gt;Take the top 4 chunks and run A includes &lt;code&gt;D&lt;/code&gt; while run B does not. Recall@8 was 1.0 and your actual recall at the size you use is 0. The metric measured a depth you never read from.&lt;/p&gt;

&lt;p&gt;Even when everything fits, position matters. Models attend unevenly across long contexts, and material buried in the middle of a large window gets used less reliably than material at the top. The practical effect today is that rank order is part of your quality, and a metric that ignores it ignores the part you can most easily improve.&lt;/p&gt;

&lt;h3&gt;
  
  
  What nDCG does
&lt;/h3&gt;

&lt;p&gt;Discounted cumulative gain gives each hit a value that shrinks with position, using a logarithm so the penalty is steep near the top and gentle further down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relevantSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;dcg&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Position 1 contributes &lt;code&gt;1 / log2(2)&lt;/code&gt; = 1.0. Position 2 contributes &lt;code&gt;1 / log2(3)&lt;/code&gt; ≈ 0.63. Position 8 contributes &lt;code&gt;1 / log2(9)&lt;/code&gt; ≈ 0.32.&lt;/p&gt;

&lt;p&gt;That shape matches how people and models use ranked results. The gap between first and second is worth more than the gap between seventh and eighth.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;n&lt;/strong&gt; is the normalisation. Raw DCG is not comparable across queries, because a query with five relevant documents can score higher than one with a single relevant document without being better retrieval. So you divide by the best achievable ordering for that query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;idcg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ideal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relevant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;idcg&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;idcg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dcg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;idcg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every relevant document first, as many as could fit in &lt;code&gt;k&lt;/code&gt;. The result is 1.0 for perfect ordering and comparable across queries with different numbers of relevant documents.&lt;/p&gt;

&lt;p&gt;For the two runs above, A scores 1.0 and B scores about 0.32, which is the difference precision and recall could not see.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which metric answers which question
&lt;/h3&gt;

&lt;p&gt;They are not competing. A report should have all of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hit_rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Did we find anything at all? The floor below which nothing else matters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;recall@k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Could the model possibly have got it right? It cannot cite what was never fetched.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;precision@k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How much noise is in the context window? This is what predicts hallucination.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mrr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How far down is the first useful thing?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ndcg@k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Is the good material near the top, or merely present?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read them in that order, and fix them in that order. Hit rate first, since ranking is irrelevant if you are not retrieving anything relevant. Then recall, which bounds everything downstream. Then nDCG, usually where the cheapest wins are, because reranking is a smaller change than reindexing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two traps in reporting them
&lt;/h3&gt;

&lt;p&gt;Report &lt;code&gt;k&lt;/code&gt; and use the &lt;code&gt;k&lt;/code&gt; you actually read. &lt;code&gt;recall@100&lt;/code&gt; is a fine diagnostic and a bad headline if your prompt takes 5 chunks.&lt;/p&gt;

&lt;p&gt;Do not average away a metric with missing inputs. A case with no relevance labels has no meaningful recall, and scoring it zero drags the average down for a labelling gap.&lt;/p&gt;

&lt;p&gt;All five are in &lt;a href="https://github.com/catidegla/ragbench" rel="noopener noreferrer"&gt;ragbench&lt;/a&gt;, computed deterministically with no model in the loop, so the same dataset and predictions give the same numbers on any machine. The nDCG test asserts exactly the property this article is about: a document found first scores higher than the same document found last, while precision and recall report both as identical.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>machinelearning</category>
      <category>search</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your eval thresholds will not catch the regression, and here is the arithmetic</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:56:03 +0000</pubDate>
      <link>https://dev.to/catidegla/your-eval-thresholds-will-not-catch-the-regression-and-here-is-the-arithmetic-56l1</link>
      <guid>https://dev.to/catidegla/your-eval-thresholds-will-not-catch-the-regression-and-here-is-the-arithmetic-56l1</guid>
      <description>&lt;p&gt;You set a threshold on your retrieval quality. &lt;code&gt;recall@k &amp;gt;= 0.8&lt;/code&gt;. The suite runs at 0.94, comfortably above, and stays green.&lt;/p&gt;

&lt;p&gt;Then someone changes the chunking strategy, and recall drops to 0.85.&lt;/p&gt;

&lt;p&gt;Your gate says nothing, because 0.85 is still above 0.8. You have lost nine points of recall, which is one question in eleven that can no longer be answered, and the check you installed to catch that reports success.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two different questions
&lt;/h3&gt;

&lt;p&gt;A threshold asks whether the system is good enough. You set it once, from what you believed acceptable at the time, and it rarely changes.&lt;/p&gt;

&lt;p&gt;A regression check asks whether this run is worse than the last one. It has no opinion about what good is.&lt;/p&gt;

&lt;p&gt;Most eval tooling gives you the first. The second catches the change nobody meant to make, because real systems do not fall off a cliff. They lose two points here and three there, each individually invisible, and a suite with headroom absorbs all of it until someone notices the product got worse and nobody can say when.&lt;/p&gt;

&lt;p&gt;The headroom is the problem. The more comfortably you pass your threshold, the more quality you can lose without being told.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the comparison needs
&lt;/h3&gt;

&lt;p&gt;Three things, and the third is the one people skip.&lt;/p&gt;

&lt;p&gt;A stored baseline, somewhere to record what "last time" was. A local SQLite file is enough.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ragbench run &lt;span class="nt"&gt;--dataset&lt;/span&gt; cases.jsonl &lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s2"&gt;"python my_rag.py"&lt;/span&gt; &lt;span class="nt"&gt;--label&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A comparison on the branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ragbench gate &lt;span class="nt"&gt;--baseline&lt;/span&gt; main &lt;span class="nt"&gt;--threshold&lt;/span&gt; recall@k&lt;span class="o"&gt;=&lt;/span&gt;0.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  recall@k       0.5000  -0.5000
  x recall@k fell 0.5000, from 1.0000 to 0.5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. The pull request is red and you know which metric moved before anyone reads the diff.&lt;/p&gt;

&lt;p&gt;A tolerance band, which decides whether the gate survives contact with reality. Retrieval scores move slightly for reasons that are not your change: an embedding service updates, a tie breaks differently, a timestamp shifts an ordering. A gate that fires on that is wrong, and a gate that fires on noise gets disabled inside a week.&lt;/p&gt;

&lt;p&gt;So drops inside the tolerance are reported as warnings. The default is one percentage point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_TOLERANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it from your own observed run-to-run variance. Run the same commit five times, see how much it moves, set the band above that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure mode nobody plans for
&lt;/h3&gt;

&lt;p&gt;What should happen when a threshold names a metric the run never produced?&lt;/p&gt;

&lt;p&gt;The comfortable answer is to skip it. The metric is not there, so there is nothing to compare, so pass.&lt;/p&gt;

&lt;p&gt;That looks like success and is not. Gate on &lt;code&gt;recall@k&lt;/code&gt;, lose the relevance labels from your dataset, and the metric stops being produced, the threshold stops being checked, and the gate reports green forever while checking nothing.&lt;/p&gt;

&lt;p&gt;So a threshold on an absent metric fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;recall@k has a threshold of 0.8 but was not produced by this run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treating it as passing hides that the check never ran, and the value of a gate is that green means something.&lt;/p&gt;

&lt;p&gt;The same reasoning covers the first run. There is no baseline yet, so the gate passes, and it says so out loud instead of reporting a clean comparison that never happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things worth refusing outright
&lt;/h3&gt;

&lt;p&gt;Three dataset rules that are all the same rule.&lt;/p&gt;

&lt;p&gt;A case with nothing to check against is rejected. No expected answer, no relevant documents, no required phrases. It passes every metric vacuously and lifts your average.&lt;/p&gt;

&lt;p&gt;A duplicate id is rejected. Predictions join to cases by id, so a duplicate makes half your suite vanish without a word.&lt;/p&gt;

&lt;p&gt;A case with no prediction scores as empty instead of skipped. Skipping lets a system that answered nothing score identically to one that answered everything correctly.&lt;/p&gt;

&lt;p&gt;Each is a way for a suite to look healthier than it is, which is the only real failure mode of an evaluation harness.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ragbench gate &lt;span class="nt"&gt;--baseline&lt;/span&gt; main &lt;span class="nt"&gt;--threshold&lt;/span&gt; recall@k&lt;span class="o"&gt;=&lt;/span&gt;0.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deterministic metrics, no LLM judge, no API key, local SQLite history. &lt;a href="https://github.com/catidegla/ragbench" rel="noopener noreferrer"&gt;ragbench&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>devops</category>
      <category>rag</category>
    </item>
    <item>
      <title>An LLM judge cannot be a build gate, and it is not about the cost</title>
      <dc:creator>Christ-loisele Atidegla</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:50:07 +0000</pubDate>
      <link>https://dev.to/catidegla/an-llm-judge-cannot-be-a-build-gate-and-it-is-not-about-the-cost-314n</link>
      <guid>https://dev.to/catidegla/an-llm-judge-cannot-be-a-build-gate-and-it-is-not-about-the-cost-314n</guid>
      <description>&lt;p&gt;Almost every RAG evaluation metric on offer needs a language model to produce it. Faithfulness, answer relevance, context precision: a model reads the answer and scores it.&lt;/p&gt;

&lt;p&gt;Those are good metrics. They measure things that are hard to measure otherwise, and for a research sweep or a quarterly quality review, use them.&lt;/p&gt;

&lt;p&gt;They cannot gate a build, and the reason people usually give is only half of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The half everyone says
&lt;/h3&gt;

&lt;p&gt;A judged evaluation costs money per run. A hundred cases across a few metrics is a few thousand model calls, which is real money on every commit and every branch.&lt;/p&gt;

&lt;p&gt;The consequence is that you move the gate. It runs nightly instead of per commit, then weekly, then on a button someone remembers to press. A check that costs a dollar gets run less, and a check that runs less catches things later, which is the property you were buying.&lt;/p&gt;

&lt;h3&gt;
  
  
  The half that matters more
&lt;/h3&gt;

&lt;p&gt;A judge is not deterministic.&lt;/p&gt;

&lt;p&gt;Run the same dataset against the same predictions twice and you get slightly different scores. Not wildly different, but different. That is workable for a report and disqualifying for a gate, because a gate exists to answer one question: did this change make things worse?&lt;/p&gt;

&lt;p&gt;Answering it means comparing two numbers. If both carry noise of unknown size, you cannot separate a regression from the measurement. You get a check that fails sometimes for no reason, and the standard response to that is to disable it, usually within a week, usually by someone under deadline pressure.&lt;/p&gt;

&lt;p&gt;So the judged metric fails twice: too expensive to run often, and untrustworthy on the difference when it does run.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is left when you remove the model
&lt;/h3&gt;

&lt;p&gt;More than you would think, and all of it deterministic.&lt;/p&gt;

&lt;p&gt;From labelled relevant documents: precision@k, recall@k, MRR, nDCG@k, hit rate. These need a golden dataset and nothing else.&lt;/p&gt;

&lt;p&gt;From expected answers: exact match after SQuAD style normalisation, token F1 for partial credit, required phrase presence for when an amount or a date must appear.&lt;/p&gt;

&lt;p&gt;Recall bounds everything downstream, so it is the one to be loudest about. The model cannot cite what retrieval never fetched. At recall@k of 0.6, forty percent of your questions were unanswerable before generation began, and no amount of prompt engineering touches that.&lt;/p&gt;

&lt;p&gt;Precision measures how much noise is in the context window, which is what predicts hallucination.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest limit
&lt;/h3&gt;

&lt;p&gt;One measure in my own tool is a proxy, and the documentation says so.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;groundedness&lt;/code&gt; is the share of answer content words that appear in the retrieved context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answerTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;predicted&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contextTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;answerTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;contextTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;answerTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is lexical overlap. It will miss a fluent misreading of a passage that was correctly retrieved, which a judge would catch. It does catch an answer invented wholesale, which is the failure that gets shipped, and it costs nothing and returns the same number every time.&lt;/p&gt;

&lt;p&gt;The trade is a cheap proxy running on every commit against an accurate measure running monthly. Which one is the better metric and which one is still switched on in March are different questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing inputs are not zeroes
&lt;/h3&gt;

&lt;p&gt;One detail decides whether people trust the output.&lt;/p&gt;

&lt;p&gt;If a case has no labelled relevant documents, the retrieval metrics for that case are omitted rather than scored zero. Aggregation skips missing values instead of averaging them in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A partially labelled dataset should report what it can measure. Scoring the gaps as zero produces a column of failures that describes your labelling rather than your system, and nobody reads a report full of zeroes twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I wrote one
&lt;/h3&gt;

&lt;p&gt;The platforms are priced for teams. Confident AI runs Free, Starter at $200 a month and Team at $2,000, with Enterprise above that. Braintrust Pro is $249. Galileo Pro is $100. Each has a free tier and each meters it: Confident AI's is two seats, one project and five test runs a week, which a per-commit gate exhausts by Tuesday. &lt;em&gt;(Checked on the vendors' own pages, 9 September 2026.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The open source libraries, RAGAS and DeepEval, compute good metrics and leave you to build the storage, the comparison and the CI gate yourself. Most solo projects end up with three tools wired together and no gate at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ragbench gate &lt;span class="nt"&gt;--baseline&lt;/span&gt; main &lt;span class="nt"&gt;--threshold&lt;/span&gt; recall@k&lt;span class="o"&gt;=&lt;/span&gt;0.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local SQLite for history, zero dependencies, nothing leaves your machine. &lt;a href="https://github.com/catidegla/ragbench" rel="noopener noreferrer"&gt;ragbench&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The gate has a second half that matters more than the thresholds, and that is the next article.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>rag</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
