<?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>Forem: Michel Faure </title>
    <description>The latest articles on Forem by Michel Faure  (@michelfaure).</description>
    <link>https://forem.com/michelfaure</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897818%2Fe862356f-3aa1-4b73-91c7-56acc29bc243.png</url>
      <title>Forem: Michel Faure </title>
      <link>https://forem.com/michelfaure</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/michelfaure"/>
    <language>en</language>
    <item>
      <title>28 % de glue code, une CI pour que ça n'augmente pas</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 30 Apr 2026 08:36:30 +0000</pubDate>
      <link>https://forem.com/michelfaure/28-de-glue-code-une-ci-pour-que-ca-naugmente-pas-2ol0</link>
      <guid>https://forem.com/michelfaure/28-de-glue-code-une-ci-pour-que-ca-naugmente-pas-2ol0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzzeepta7pne6cr32xddf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzzeepta7pne6cr32xddf.png" alt="Strip BD — Étienne, l'associé majoritaire venu du M&amp;amp;A software, lit le rapport de Michel et lâche la punchline Sculley : « You're selling glue is the most honest number on your dashboard. »"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Le jour où &lt;code&gt;lib/&lt;/code&gt; a cessé d'être lisible
&lt;/h2&gt;

&lt;p&gt;Un dimanche après-midi, je lance &lt;code&gt;ls lib/&lt;/code&gt; dans Rembrandt, l'ERP que je code seul pour L'Atelier Palissy depuis un mois. Il y a un mois, quand j'ai démarré, &lt;code&gt;lib/&lt;/code&gt; tenait dans un écran de MacBook. Je pouvais parcourir les noms d'un seul coup d'œil et savoir ce que chacun faisait. Ce dimanche-là, j'en compte quarante-et-un. Treize sont des adapters vers des services tiers — Supabase, Gmail, Brevo, Slack, Stripe, Meta CAPI, QStash, Push, PennyLane — et chacun fait entre 120 et 260 lignes de plumbing honnête. Rien ne plante, tout marche. Pourtant le dossier qui m'inspirait une lecture me demande maintenant un effort de scroll.&lt;/p&gt;

&lt;p&gt;Je m'aperçois que chaque nouvelle intégration que j'ai demandée à Claude Code s'est cristallisée en un fichier d'adapter de cette taille, parce qu'un adapter est facile à générer : signature claire, pas d'invariant métier à protéger, pas de test à écrire. Mon agent a fait exactement ce que je lui demandais, à chaque fois, et par sédimentation quotidienne il a produit le genre de base dont Sculley et ses co-auteurs de Google écrivaient en 2015 qu'elle finit, dans les systèmes pathologiques, par représenter &lt;em&gt;95 % de code de glue pour 5 % de logique métier&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Il y a quelques semaines, Gaspard, notre prestataire informatique de longue date, était passé au bureau pour une raison qui m'échappe aujourd'hui. Je lui ai montré un début de &lt;code&gt;lib/&lt;/code&gt; sur l'écran, fier de la progression. Il a fait défiler trois secondes, sans s'asseoir, et lâché sans lever les yeux : &lt;em&gt;« C'est de la plomberie, ça. »&lt;/em&gt; J'avais acquiescé comme on acquiesce à un commentaire technique qu'on ne comprend pas encore, en me disant qu'il parlait d'un détail. Je comprends six semaines plus tard qu'il venait de nommer en deux mots ce que Sculley et la littérature sur la dette technique essaient d'articuler depuis dix ans.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Si tu as 30 secondes.&lt;/strong&gt; Le glue code (adapters, format conversions, plumbing vers des APIs externes) prolifère silencieusement quand on code vite, et encore plus vite quand on code avec un LLM qui produit volontiers des adapters. La parade : mesurer le ratio glue/business sur &lt;code&gt;lib/&lt;/code&gt; avec un script de 130 lignes, et brancher une CI sur la &lt;strong&gt;non-régression&lt;/strong&gt; plutôt que sur un seuil absolu. Cet article donne le script, le pattern CI, et pourquoi la non-régression vaut mieux qu'un plafond. Utile si tu pilotes une base qui parle à beaucoup de services externes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Le cadre qui m'a manqué pendant trois semaines
&lt;/h2&gt;

&lt;p&gt;L'article &lt;em&gt;Hidden Technical Debt in Machine Learning Systems&lt;/em&gt; (Sculley et al., NIPS 2015) décrit une dette particulière aux systèmes ML : le code utile au modèle est une toute petite boîte au centre d'un grand écosystème de plumbing — ingestion de données, normalisation, serving, monitoring. Le ratio type qu'ils constatent en production, 5/95. Les auteurs ne prétendent pas que le glue est mauvais en soi, ils prétendent que quand il n'est pas nommé, il se paie en coûts cachés : chaque refacto devient acrobatique, chaque migration se négocie avec dix fichiers qui ne devraient pas être concernés.&lt;/p&gt;

&lt;p&gt;Le cadre est ML mais la forme dépasse largement. Dès qu'un système parle à cinq ou six services externes, il en produit, du glue. Un ERP vertical coché de six intégrations tierces est structurellement condamné à en fabriquer, et le risque n'est pas qu'il y en ait — il y en aura — mais qu'il soit &lt;strong&gt;compté comme du code métier&lt;/strong&gt; dans l'équation mentale du développeur. Le jour où je relis &lt;code&gt;lib/supabase-paginate.ts&lt;/code&gt; en me disant que c'est une brique métier, j'ai perdu. C'est un adapter, il doit rester un adapter, il doit être nommé tel, et son volume doit entrer dans une métrique dont la courbe a le droit de m'inquiéter.&lt;/p&gt;

&lt;p&gt;Neuf ans avant Sculley, Moseley et Marks avaient posé dans &lt;em&gt;Out of the Tar Pit&lt;/em&gt; (2006) la distinction fondatrice qui donne sa grille au problème : complexité &lt;em&gt;essentielle&lt;/em&gt;, qui vient du métier, et complexité &lt;em&gt;accidentelle&lt;/em&gt;, qui vient de la solution technique choisie. Le glue, dans cette grille, est de la complexité accidentelle à l'état pur. Il ne sert aucune exigence métier, il résout seulement le fait que deux systèmes ne parlent pas la même langue. C'est cette asymétrie — essentiel se paie une fois, accidentel se paie à chaque lecture, à chaque refacto, à chaque migration — qui explique que le glue devient dangereux bien avant d'être majoritaire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le script, cent trente lignes
&lt;/h2&gt;

&lt;p&gt;J'ai écrit &lt;code&gt;scripts/glue-ratio.sh&lt;/code&gt; un après-midi, un peu contre moi-même. Deux listes en dur : la liste des fichiers &lt;code&gt;lib/*.ts&lt;/code&gt; qui sont du glue, la liste de ceux qui sont de la logique métier. Tout nouveau fichier que j'ajoute doit être classé consciemment dans l'une des deux. Rien n'est automatique, et c'est le seul moyen que chaque décision d'ajout soit une décision nommée.&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="nv"&gt;GLUE_FILES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/supabase.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/supabase-admin.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/supabase-server.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/supabase-paginate.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/gmail.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/gmail-api.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/brevo.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/slack.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/stripe.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/meta-capi.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/pennylane.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/qstash.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/push.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/rate-limit.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/cache.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/webhook-idempotency.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/wordpress.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/utils.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/database.types.ts"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;BUSINESS_FILES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/rembrandt.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/rembrandt-tool-defs.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/rembrandt-tool-handlers.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/lead-pipeline.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/email-outbox.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/email-templates.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/permissions.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/contacts.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/calendrier.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/segments.ts"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le reste du script additionne les lignes, calcule deux pourcentages (global et hors &lt;code&gt;database.types.ts&lt;/code&gt;), et écrit un verdict court. Le mode &lt;code&gt;--metric&lt;/code&gt; sort uniquement le ratio hors-types, prévu pour être comparé en CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le piège des types auto-générés
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lib/database.types.ts&lt;/code&gt; est un fichier auto-généré par Supabase à partir du schema. Il pèse plus de vingt mille lignes sur Rembrandt, et comme il est entièrement du glue (définitions TypeScript des tables, rien de métier), il fait basculer le ratio global au-delà de 60 % si on le compte. Ce serait juste, et ce serait inutile, parce que personne ne décide rien en relisant ce fichier. La règle que j'ai fini par poser est : &lt;strong&gt;le ratio de référence est hors &lt;code&gt;database.types.ts&lt;/code&gt;&lt;/strong&gt;. Le script expose les deux chiffres, le global pour mémoire et le hors-types pour piloter. Ratio actuel du repo : &lt;strong&gt;28 % hors types&lt;/strong&gt; sur &lt;code&gt;main&lt;/code&gt;. Cible que je me donne : sous 25 % durablement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ratio glue/business — lib/
==========================
  Glue:     22 183 lignes (64%)
  Business: 12 487 lignes (36%)
  Total:    34 670 lignes

  (hors database.types.ts : 2 183 glue / 14 670 total = 28%)

  OK: glue hors-types sous le seuil d'alerte 30% (cible 25%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  La CI qui bloque la régression, pas l'absolu
&lt;/h2&gt;

&lt;p&gt;Voici le choix que j'ai mis du temps à faire, et qui compte plus que le script lui-même. On écrit souvent un garde-fou CI avec un seuil absolu : &lt;code&gt;if (glue &amp;gt; 30%) fail&lt;/code&gt;. C'est séduisant parce que c'est simple, et c'est une mauvaise idée. Un projet mature à 35 % de glue qui se tient peut être parfaitement sain. Un projet à 18 % qui monte à 22 % en une semaine est en train de dériver. Le seuil absolu ne voit pas la dérive, il ne voit que l'arrivée.&lt;/p&gt;

&lt;p&gt;J'ai branché la CI sur la &lt;strong&gt;non-régression&lt;/strong&gt; entre &lt;code&gt;HEAD&lt;/code&gt; et &lt;code&gt;origin/main&lt;/code&gt;, avec une tolérance de zéro point. Chaque PR qui fait monter le ratio plus haut que &lt;code&gt;main&lt;/code&gt; fail, et le message lève la vraie question : &lt;em&gt;« est-ce que tu ajoutes de la logique métier qui justifie plus de plumbing, ou est-ce que tu ajoutes un adapter qui n'a rien demandé à personne ? »&lt;/em&gt;. Si c'est le premier cas, tu ajoutes du business en face, le ratio baisse, la PR passe. Si c'est le second, tu cherches à extraire, à mutualiser, à renommer.&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;# scripts/glue-ratio-check.sh (extrait)&lt;/span&gt;
&lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bash scripts/glue-ratio.sh &lt;span class="nt"&gt;--metric&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$tmp"'&lt;/span&gt; EXIT
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;/scripts"&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;scripts/glue-ratio.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;/scripts/"&lt;/span&gt;
git archive &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_REF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; lib/ | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bash scripts/glue-ratio.sh &lt;span class="nt"&gt;--metric&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;current &lt;span class="o"&gt;-&lt;/span&gt; base&lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$delta&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOLERANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ECHEC: le ratio glue a augmente de &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;delta&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; pts (tolerance +&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TOLERANCE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)."&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Regarder si du glue peut etre extrait dans lib/mappings/ ou lib/adapters/,"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ou si un nouveau fichier est mal categorise dans scripts/glue-ratio.sh."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un filet secondaire, pour les cas pathologiques : au-delà de 40 %, le script sort en mode alerte dans la sortie humaine, ce qui impose un débat d'équipe même si la non-régression passe. Mais c'est un filet, pas la métrique principale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi une règle écrite dans &lt;code&gt;CLAUDE.md&lt;/code&gt; ne suffit pas
&lt;/h2&gt;

&lt;p&gt;J'avais d'abord écrit une règle dans mon &lt;code&gt;CLAUDE.md&lt;/code&gt;, formulée à peu près comme &lt;em&gt;« privilégier la logique métier aux adapters, garder &lt;code&gt;lib/&lt;/code&gt; mince »&lt;/em&gt;. Cette règle n'a rien empêché. Elle ne se heurtait à aucun fait, et un adapter qui semble nécessaire sur le moment l'emporte toujours sur une phrase abstraite lue en haut du fichier de contraintes. La métrique chiffrée, elle, renvoie un fait matériel à la tête du rédacteur : &lt;em&gt;+3 points sur cette PR&lt;/em&gt;. Le débat devient concret, la règle devient opposable, et le rédacteur — humain ou LLM — prend conscience de ce qu'il est en train de faire. C'est exactement ce que le &lt;code&gt;CLAUDE.md&lt;/code&gt; ne peut pas produire tant qu'il reste du texte.&lt;/p&gt;

&lt;p&gt;Il y a là une leçon qui dépasse la métrique elle-même. &lt;strong&gt;Les disciplines qui tiennent ont toutes un chiffre que la machine calcule à ta place.&lt;/strong&gt; Pas une intention, pas un principe, pas un vœu — un chiffre. Le reste s'érode au rythme de la fatigue du développeur et de la complaisance de l'agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier dans ton projet
&lt;/h2&gt;

&lt;p&gt;Les deux scripts et un exemple de workflow CI vivent dans le repo compagnon, licence MIT : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/glue-ratio" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Quatre gestes directement applicables si ta base a beaucoup d'intégrations externes :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Poser deux listes en dur&lt;/strong&gt; dans un script shell, glue et business, et obliger toute nouvelle addition à être classée dans l'une ou l'autre. Pas de détection automatique — la friction est le point&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclure les fichiers auto-générés&lt;/strong&gt; du dénominateur. Les exposer en chiffre global pour mémoire, mais piloter sur le ratio hors-types&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brancher la CI sur la non-régression&lt;/strong&gt;, pas sur un seuil absolu. Tolérance zéro point, message qui pose la vraie question au rédacteur de la PR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filet secondaire&lt;/strong&gt; à 40 % pour les cas pathologiques, mais c'est un filet, pas la règle&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et une discipline plus large : &lt;strong&gt;tout ce qui n'est pas mesuré dérive&lt;/strong&gt;. Une règle dans un fichier de contraintes est lue, puis oubliée ; une métrique chiffrée qui bloque une PR est contournée consciemment ou non, mais elle est vue. Les LLM ne font pas exception à cette règle — ils la rendent même plus urgente, parce qu'ils produisent plus vite ce qu'on ne leur demande pas de modérer.&lt;/p&gt;

&lt;p&gt;Et vous, quelles métriques pilotent réellement vos PR, et lesquelles sont restées des intentions ? Je lis les commentaires.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/glue-ratio" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/glue-ratio/&lt;/code&gt;&lt;/a&gt; — le script de mesure, le filet de non-régression CI, et le workflow GitHub Actions, licence MIT.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>ci</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>28% glue code, a CI rule to keep it from growing</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 30 Apr 2026 08:36:29 +0000</pubDate>
      <link>https://forem.com/michelfaure/28-glue-code-a-ci-rule-to-keep-it-from-growing-478d</link>
      <guid>https://forem.com/michelfaure/28-glue-code-a-ci-rule-to-keep-it-from-growing-478d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzzeepta7pne6cr32xddf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzzeepta7pne6cr32xddf.png" alt="Comic strip — Étienne, the majority-stakeholder partner from M&amp;amp;A software, reads Michel's metrics report and lands the Sculley punchline: "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The day &lt;code&gt;lib/&lt;/code&gt; stopped being readable
&lt;/h2&gt;

&lt;p&gt;One Sunday afternoon, I run &lt;code&gt;ls lib/&lt;/code&gt; in Rembrandt, the ERP I've been coding alone for L'Atelier Palissy for a month. A month ago, when I started, &lt;code&gt;lib/&lt;/code&gt; fit in one MacBook screen. I could scan the names at a glance and know what each one did. That Sunday, I count forty-one. Thirteen are adapters to third-party services — Supabase, Gmail, Brevo, Slack, Stripe, Meta CAPI, QStash, Push, PennyLane — and each one is between 120 and 260 lines of honest plumbing. Nothing crashes, everything works. And yet the folder that used to invite a reading now asks me to scroll.&lt;/p&gt;

&lt;p&gt;I realize that every new integration I asked Claude Code for crystallized into an adapter file of this size, because an adapter is easy to generate: clear signature, no business invariant to protect, no test to write. My agent did exactly what I asked, every time, and through daily sedimentation it produced the kind of codebase that Sculley and his Google co-authors described in 2015 as ending up, in pathological systems, as &lt;em&gt;95% glue code for 5% business logic&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A few weeks ago, Gaspard — our long-time IT contractor — dropped by the office for a reason that escapes me today. I showed him an early &lt;code&gt;lib/&lt;/code&gt; on screen, proud of the progress. He scrolled for three seconds without sitting down, and said without looking up: &lt;em&gt;« C'est de la plomberie, ça. »&lt;/em&gt; — &lt;em&gt;That's plumbing, right there.&lt;/em&gt; I nodded the way you nod at a technical remark you don't quite understand yet, assuming he meant a detail. Six weeks later I understand that he had just named, in two words, what Sculley and the technical-debt literature have been trying to articulate for ten years.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you have 30 seconds.&lt;/strong&gt; Glue code (adapters, format conversions, plumbing to external APIs) proliferates silently when you code fast, and even faster when you code with an LLM that happily produces adapters. The countermeasure: measure the glue/business ratio in &lt;code&gt;lib/&lt;/code&gt; with a 130-line script, and hook the CI to &lt;strong&gt;non-regression&lt;/strong&gt; rather than an absolute threshold. This article gives the script, the CI pattern, and why non-regression beats a cap. Useful if you run a codebase that talks to many external services.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The framing I missed for three weeks
&lt;/h2&gt;

&lt;p&gt;The paper &lt;em&gt;Hidden Technical Debt in Machine Learning Systems&lt;/em&gt; (Sculley et al., NIPS 2015) describes a particular debt in ML systems: the code useful to the model is a tiny box at the center of a large plumbing ecosystem — data ingestion, normalization, serving, monitoring. The typical ratio they observe in production, 5/95. The authors don't claim glue is bad in itself; they claim that when it isn't named, it gets paid in hidden costs: every refactor becomes acrobatic, every migration is negotiated with ten files that shouldn't be concerned.&lt;/p&gt;

&lt;p&gt;The framing is ML but the form extends far beyond. As soon as a system talks to five or six external services, it produces glue. A vertical ERP with six third-party integrations is structurally condemned to manufacture it, and the risk isn't that there is some — there will be — but that it gets &lt;strong&gt;counted as business code&lt;/strong&gt; in the developer's mental equation. The day I reread &lt;code&gt;lib/supabase-paginate.ts&lt;/code&gt; thinking it's a business brick, I've lost. It's an adapter, it must remain an adapter, it must be named as such, and its volume must enter a metric whose curve is entitled to worry me.&lt;/p&gt;

&lt;p&gt;Nine years before Sculley, Moseley and Marks had laid down in &lt;em&gt;Out of the Tar Pit&lt;/em&gt; (2006) the founding distinction that gives the problem its grid: &lt;em&gt;essential&lt;/em&gt; complexity, which comes from the business, and &lt;em&gt;accidental&lt;/em&gt; complexity, which comes from the technical solution chosen. Glue, in this grid, is accidental complexity in its purest form. It serves no business requirement; it only solves the fact that two systems don't speak the same language. It's this asymmetry — essential is paid once, accidental is paid at every reading, every refactor, every migration — that explains why glue becomes dangerous well before it becomes dominant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script, one hundred and thirty lines
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;code&gt;scripts/glue-ratio.sh&lt;/code&gt; one afternoon, a bit against myself. Two hardcoded lists: the &lt;code&gt;lib/*.ts&lt;/code&gt; files that are glue, and the ones that are business logic. Every new file I add must be consciously classified into one of the two. Nothing is automatic, and that's the only way every addition decision is a named decision.&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="nv"&gt;GLUE_FILES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/supabase.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/supabase-admin.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/supabase-server.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/supabase-paginate.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/gmail.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/gmail-api.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/brevo.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/slack.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/stripe.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/meta-capi.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/pennylane.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/qstash.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/push.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/rate-limit.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/cache.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/webhook-idempotency.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/wordpress.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/utils.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/database.types.ts"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;BUSINESS_FILES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/rembrandt.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/rembrandt-tool-defs.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/rembrandt-tool-handlers.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/lead-pipeline.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/email-outbox.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/email-templates.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/permissions.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/contacts.ts"&lt;/span&gt;
  &lt;span class="s2"&gt;"lib/calendrier.ts"&lt;/span&gt; &lt;span class="s2"&gt;"lib/segments.ts"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the script sums lines, computes two percentages (global, and excluding &lt;code&gt;database.types.ts&lt;/code&gt;), and prints a short verdict. The &lt;code&gt;--metric&lt;/code&gt; mode only outputs the types-excluded ratio, designed to be compared in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auto-generated types trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lib/database.types.ts&lt;/code&gt; is a file auto-generated by Supabase from the schema. It weighs over twenty thousand lines in Rembrandt, and since it is entirely glue (TypeScript definitions of tables, nothing business), it tips the global ratio above 60% if counted. That would be accurate, and useless, because no one makes a decision by rereading that file. The rule I eventually settled on: &lt;strong&gt;the reference ratio is excluding &lt;code&gt;database.types.ts&lt;/code&gt;&lt;/strong&gt;. The script exposes both figures — global for the record, types-excluded to steer by. Current repo ratio: &lt;strong&gt;28% excluding types&lt;/strong&gt; on &lt;code&gt;main&lt;/code&gt;. Target I set myself: under 25% durably.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;glue/business ratio — lib/
==========================
  Glue:     22,183 lines (64%)
  Business: 12,487 lines (36%)
  Total:    34,670 lines

  (excluding database.types.ts: 2,183 glue / 14,670 total = 28%)

  OK: glue excl. types below 30% alert threshold (target 25%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The CI that blocks regression, not an absolute
&lt;/h2&gt;

&lt;p&gt;Here's the choice that took me time to make, and that matters more than the script itself. CI guardrails are often written with an absolute threshold: &lt;code&gt;if (glue &amp;gt; 30%) fail&lt;/code&gt;. It's seductive because it's simple, and it's a bad idea. A mature project at 35% glue that holds can be perfectly healthy. A project at 18% rising to 22% in a week is drifting. The absolute threshold doesn't see the drift, it only sees the arrival.&lt;/p&gt;

&lt;p&gt;I hooked the CI to &lt;strong&gt;non-regression&lt;/strong&gt; between &lt;code&gt;HEAD&lt;/code&gt; and &lt;code&gt;origin/main&lt;/code&gt;, with a tolerance of zero points. Any PR that raises the ratio above &lt;code&gt;main&lt;/code&gt; fails, and the message asks the real question: &lt;em&gt;"are you adding business logic that justifies more plumbing, or are you adding an adapter that nobody asked for?"&lt;/em&gt;. If the former, you add business alongside, the ratio drops, the PR passes. If the latter, you look to extract, to share, to rename.&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;# scripts/glue-ratio-check.sh (excerpt)&lt;/span&gt;
&lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bash scripts/glue-ratio.sh &lt;span class="nt"&gt;--metric&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$tmp"'&lt;/span&gt; EXIT
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;/scripts"&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;scripts/glue-ratio.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;/scripts/"&lt;/span&gt;
git archive &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_REF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; lib/ | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bash scripts/glue-ratio.sh &lt;span class="nt"&gt;--metric&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;delta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;current &lt;span class="o"&gt;-&lt;/span&gt; base&lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$delta&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOLERANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: glue ratio increased by &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;delta&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; pts (tolerance +&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TOLERANCE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)."&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Check if glue can be extracted into lib/mappings/ or lib/adapters/,"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"or if a new file is miscategorized in scripts/glue-ratio.sh."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A secondary safety net, for pathological cases: above 40%, the script enters alert mode in the human output, which forces a team debate even if non-regression passes. But it's a net, not the main metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a rule written in &lt;code&gt;CLAUDE.md&lt;/code&gt; isn't enough
&lt;/h2&gt;

&lt;p&gt;I had first written a rule in my &lt;code&gt;CLAUDE.md&lt;/code&gt;, phrased roughly as &lt;em&gt;"prefer business logic over adapters, keep &lt;code&gt;lib/&lt;/code&gt; thin"&lt;/em&gt;. That rule prevented nothing. It stood against no fact, and an adapter that seems necessary in the moment always wins against an abstract sentence read at the top of a constraints file. A numerical metric, on the other hand, pushes a material fact at the writer's head: &lt;em&gt;+3 points on this PR&lt;/em&gt;. The debate becomes concrete, the rule becomes opposable, and the writer — human or LLM — becomes aware of what they are doing. That's exactly what the &lt;code&gt;CLAUDE.md&lt;/code&gt; cannot produce as long as it remains text.&lt;/p&gt;

&lt;p&gt;There's a lesson here that goes beyond the metric itself. &lt;strong&gt;Disciplines that hold all have a number the machine computes for you.&lt;/strong&gt; Not an intention, not a principle, not a wish — a number. The rest erodes at the pace of developer fatigue and agent complacency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy into your project
&lt;/h2&gt;

&lt;p&gt;Both scripts and a CI workflow example live in the companion repo, MIT license: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/glue-ratio" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Four directly applicable moves if your codebase has many external integrations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Maintain two hardcoded lists&lt;/strong&gt; in a shell script, glue and business, and force every new addition to be classified into one or the other. No automatic detection — the friction is the point&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude auto-generated files&lt;/strong&gt; from the denominator. Expose them as a global figure for the record, but steer on the types-excluded ratio&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hook the CI to non-regression&lt;/strong&gt;, not an absolute threshold. Zero-point tolerance, message that asks the real question of the PR writer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secondary safety net&lt;/strong&gt; at 40% for pathological cases, but it's a net, not the rule&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And a broader discipline: &lt;strong&gt;anything that isn't measured drifts&lt;/strong&gt;. A rule in a constraints file is read, then forgotten; a numerical metric that blocks a PR is bypassed consciously or not, but it is seen. LLMs are no exception to this rule — they make it more urgent, because they produce faster what they aren't asked to moderate.&lt;/p&gt;

&lt;p&gt;And you — which metrics actually drive your PRs, and which have stayed as intentions? I read the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/glue-ratio" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/glue-ratio/&lt;/code&gt;&lt;/a&gt; — the measurement script, the non-regression CI gate, and the GitHub Actions workflow, MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>ci</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>J'ai ajouté 20 lignes de code pour empêcher mon ERP de me mentir</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Wed, 29 Apr 2026 08:33:58 +0000</pubDate>
      <link>https://forem.com/michelfaure/jai-ajoute-20-lignes-de-code-pour-empecher-mon-erp-de-me-mentir-l6h</link>
      <guid>https://forem.com/michelfaure/jai-ajoute-20-lignes-de-code-pour-empecher-mon-erp-de-me-mentir-l6h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5c3z1fmtxms2vs97dru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5c3z1fmtxms2vs97dru.png" alt="Strip BD — Michel voit un bond de +9 318 lignes sur son dashboard, hésite, code un watcher, reçoit le verdict « Now you lie on record »" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook
&lt;/h2&gt;

&lt;p&gt;Le 14 avril, 6 h 47. Mon dashboard m'annonce fièrement une progression de &lt;strong&gt;9 318 lignes&lt;/strong&gt; depuis la veille. Sur les 9 318, il y en a &lt;strong&gt;5 037&lt;/strong&gt; qui viennent d'un dump SQL de migrations déjà existantes. Un export technique, pas une ligne de travail nouveau. Et pourtant le compteur monte, la jauge de valorisation se décale, et l'achievement « 100K lignes » clignote en vert. Je regarde ça cinq secondes, café en main. Je comprends que mon propre outil est en train de me mentir avec mon consentement. Pire : il le fait depuis trois semaines, et je le savais.&lt;/p&gt;

&lt;p&gt;Quelques jours plus tôt, Antoine était passé dans mon bureau à huit heures moins le quart, main posée sur le chambranle. L'ancien gérant, 73 ans, part en retraite en septembre. Il ne s'est pas assis. « Michel, combien vaut la maison aujourd'hui, dis-moi ? » Je lui ai répondu une phrase qui ne disait rien. « Évidemment. Bon, on avance. » Il est reparti. La question est restée.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Si tu as 30 secondes.&lt;/strong&gt; Mesurer la valeur d'un logiciel interne avec &lt;code&gt;lignes × TJM&lt;/code&gt; produit un chiffre qui diverge de la réalité au fur et à mesure que l'IA baisse le coût d'écriture. Cet article raconte pourquoi j'ai codé mon propre instrument de valorisation plutôt que de déléguer à un cabinet, la thèse économique qui le justifie (le bien singulier a besoin d'un dispositif de jugement), et le garde-fou de vingt lignes qui empêche mon compteur de me mentir. Utile si tu pilotes un outil interne sans prix de marché.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Le vide de prix
&lt;/h2&gt;

&lt;p&gt;Je dirige une école d'art céramique à Paris et en région parisienne, six sites, plusieurs centaines d'élèves. Depuis vingt-neuf jours, je code seul avec Claude Code l'ERP métier qui remplace notre empilement d'outils. Le système s'appelle Rembrandt. À la date où j'écris, il compte &lt;strong&gt;91 000 lignes&lt;/strong&gt; de TypeScript, &lt;strong&gt;377 commits&lt;/strong&gt; en quatre semaines, &lt;strong&gt;16 décisions d'architecture&lt;/strong&gt; documentées. Je ne suis pas développeur de formation.&lt;/p&gt;

&lt;p&gt;Un objet comme celui-là ne rencontre jamais son prix. Personne n'achète un ERP vertical pour une école d'art de six sites sur un marché qui n'existe pas. Et le coût de production ne raconte plus grand-chose non plus, parce qu'il a été divisé par dix en dix-huit mois et continue de baisser. Dans ce vide entre le prix qui n'existe pas et le coût qui ne signifie plus rien, il faut bien qu'une mesure tienne lieu de boussole. Par défaut, c'est le compteur de lignes multiplié par un TJM senior. Chacun connaît l'équation. Elle est séduisante parce qu'elle donne un chiffre, et qu'un chiffre fait exister l'objet comme actif plutôt que comme bricolage.&lt;/p&gt;

&lt;p&gt;Pendant trois semaines, j'ai regardé mon dashboard monter avec cette équation dans le ventre. Jusqu'au matin du 14 avril.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le détour par l'extérieur
&lt;/h2&gt;

&lt;p&gt;Un lundi, dans une salle de réunion parisienne, nous avions signé avec un éditeur ERP commercial européen très connu. Licences annuelles, un pack de consulting à cinq chiffres, reconduction tacite. Tout le monde souriait. Ce que personne n'a lu à voix haute, c'est la grille de facturation des développements custom : &lt;strong&gt;au nombre de lignes produites&lt;/strong&gt;. L'annexe technique, page 14. Une ligne = une unité de valeur. Nous avons paraphé.&lt;/p&gt;

&lt;p&gt;Trois jours plus tard, en relisant le contrat dans mon bureau, j'ai compris avec un peu de retard que la métrique produit le code autant qu'elle le mesure : quand on paie à la ligne, on reçoit des lignes. J'ai appelé l'éditeur. J'ai demandé où s'arrêtait la prestation prévue et où commençait la facturation au réel. La réponse a été cordiale, circulaire. À ce jour l'éditeur refuse tout remboursement et la négociation est encore ouverte.&lt;/p&gt;

&lt;p&gt;C'est le samedi suivant, cinq jours après la signature, que j'ai ouvert Claude Code pour la première fois.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le week-end de la bascule
&lt;/h2&gt;

&lt;p&gt;Je ne raconte pas ce samedi pour faire du récit. Je le raconte parce qu'il contient, en germe, l'erreur que j'allais reproduire contre moi-même.&lt;/p&gt;

&lt;p&gt;J'ai basculé &lt;strong&gt;parce que la métrique au LOC ne tenait plus chez eux&lt;/strong&gt;. Une ligne de code facturée comme unité de valeur, dans un monde où écrire une ligne coûte dix fois moins cher qu'il y a deux ans. Il ne faut pas beaucoup de recul pour voir que cette unité ne tient plus chez un prestataire.&lt;/p&gt;

&lt;p&gt;Quarante-huit heures plus tard, j'avais quelque chose qui tournait. Un schema Supabase, trois routes Next.js, une page d'authentification fonctionnelle. Rien de spectaculaire. Juste la preuve que l'alternative existait, et qu'elle tenait dans un week-end.&lt;/p&gt;

&lt;p&gt;Il en faut un peu plus pour comprendre qu'on s'applique à soi-même la métrique qu'on avait enterrée chez le prestataire. Et c'est pourtant exactement ce que mon dashboard faisait depuis vingt-et-un jours, avec ma bénédiction. &lt;code&gt;lines_total × 15 €&lt;/code&gt; quelque part au fond d'une fonction, et une jauge qui montait toute seule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trois dérives que le compteur ne voit pas
&lt;/h2&gt;

&lt;p&gt;La première dérive est la plus simple. Le coût de production chute, le compteur monte, l'écart se creuse mécaniquement. À l'horizon 2028, je pourrais afficher 200 000 lignes pour un coût réel de quelques dizaines de milliers d'euros. Aucun expert-comptable ne signera ça sans lever un sourcil. Aucun repreneur ne paiera ça sans audit. La métrique ment de plus en plus fort, et elle ment d'autant plus fort qu'on la laisse monter.&lt;/p&gt;

&lt;p&gt;La deuxième dérive est plus subtile. Sur les 91 000 lignes, environ 10 000 font du CRUD tout venant sur des contacts et des formulaires, remplaçables en une matinée par un SaaS à 100 euros par mois. D'autres paquets de 10 000 lignes encodent la logique des rattrapages quatre périodes par an sur six sites avec des règles Qualiopi que personne d'autre que nous n'a jamais eu besoin de formaliser. Même volume, valeurs réelles incomparables. Le compteur voit des octets là où il faudrait voir du singulier et du commoditisable séparés.&lt;/p&gt;

&lt;p&gt;La troisième dérive est celle qui m'a fait basculer. Le vrai patrimoine de Rembrandt n'est pas dans le code. Il est dans environ 3 000 contacts historicisés, 5 000 leads qualifiés, 800 inscriptions vivantes, trois ans d'historique financier redressé, et seize décisions d'architecture qui cristallisent pourquoi nous faisons les choses ainsi et pas autrement. Rien de tout cela ne pèse une ligne de code. Tout cela pèse une part significative de ce qu'on paierait pour reprendre l'outil.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le garde-fou qui a tranché
&lt;/h2&gt;

&lt;p&gt;Le lendemain du 14 avril, j'ai ajouté dans le cron de snapshot un garde-fou de vingt lignes. L'idée est simple : tout bump anormal de &lt;code&gt;lines_total&lt;/code&gt; doit produire un avertissement avant d'être encaissé comme « progression ».&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/compute-valorisation-donnees/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valorisation_snapshots&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lines_total&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="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;snapshot_date&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="na"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last7&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;lines_total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`:warning: bump anormal lines_total : +&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`(moyenne 7j ~&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;). `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Vérifier avant comptabilisation valeur.`&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;Ce n'est pas sophistiqué. C'est vingt lignes de TypeScript qui appellent un webhook Slack. Mais ces vingt lignes disent quelque chose que les vingt-et-un jours précédents ne disaient pas : &lt;strong&gt;un compteur automatique qui entre dans un calcul de valeur doit avoir un veilleur&lt;/strong&gt;. Sans veilleur, la métrique devient un oracle qui se croit sur parole. C'est exactement ce qui s'était passé avec l'éditeur commercial. C'est exactement ce que je m'apprêtais à me faire à moi-même.&lt;/p&gt;

&lt;p&gt;Je pensais aussi à Antoine en écrivant ce garde-fou. Il ne posera pas la question deux fois, et je ne veux pas lui sortir un chiffre que je n'ai pas construit moi-même. « Vous êtes sûr ? » est une phrase courte qui demande, derrière elle, une méthode qu'on puisse tenir debout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le dispositif de jugement
&lt;/h2&gt;

&lt;p&gt;Il y a une thèse économique, discrète mais utile, qui dit que les biens singuliers — ceux qui n'ont pas de marché parce qu'ils sont uniques et qualitativement jugés plutôt que quantitativement comparés — ont besoin d'un &lt;strong&gt;dispositif de jugement&lt;/strong&gt; pour circuler, se défendre, se valoriser. Karpik l'a formalisée pour les vins, les livres, les médecins. Elle s'applique mot pour mot à un ERP sur-mesure. Aucun marché ne produit son prix. C'est le dispositif qui produit sa valeur discutable.&lt;/p&gt;

&lt;p&gt;Ce qui se joue alors dans le fait de coder soi-même son module de valorisation n'est pas décoratif. L'instrument ne constate pas une valeur préexistante qui traînerait quelque part, prête à être lue. Il la &lt;strong&gt;fabrique comme opposable&lt;/strong&gt; : chaque euro qu'il affiche doit pouvoir être justifié par une méthode transparente et une source traçable. C'est cela qui rend l'objet défendable devant un expert-comptable, devant une administration fiscale, devant un repreneur éventuel. Sans dispositif, il n'y a pas de valeur — il y a un ressenti de directeur qui a beaucoup codé.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que ça donne en code
&lt;/h2&gt;

&lt;p&gt;Concrètement, le module &lt;code&gt;valorisation&lt;/code&gt; tient dans une table de snapshots et quatre tables de dimensions. Le cœur de l'API consolidée ressemble à ça :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/valorisation/compute.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Dimension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;donnees&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strategique&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;        &lt;span class="c1"&gt;// table ou méthode d'origine&lt;/span&gt;
  &lt;span class="na"&gt;refreshed_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;  &lt;span class="c1"&gt;// ISO date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;consolidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Dimension&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;present&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dims&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;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;value_low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;low&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="na"&gt;value_high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&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="na"&gt;dims_used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&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="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;Trois choses que ce bout de code dit clairement. &lt;strong&gt;On somme&lt;/strong&gt; les dimensions, on ne prend pas le max, on ne pondère pas. &lt;strong&gt;On garde trace&lt;/strong&gt; des dimensions utilisées dans chaque snapshot, pour pouvoir expliquer plus tard pourquoi un intervalle a bougé. &lt;strong&gt;On accepte le null&lt;/strong&gt; : si une dimension n'est pas encore instrumentée, elle ne casse pas le calcul, elle s'absente honnêtement.&lt;/p&gt;

&lt;p&gt;Le détail des quatre dimensions mérite un article à part, et c'est le suivant dans cette série. Ici j'essaie seulement de dire ce que j'ai compris le 14 avril au matin. Un instrument de mesure qu'on se donne à soi-même n'est pas un tableau de bord de plus. C'est le geste par lequel un objet sans prix devient un actif dont on peut parler. Tant que l'instrument est faux, l'actif reste un bricolage qui se raconte des histoires. Quand l'instrument commence à tenir, l'objet commence à exister.&lt;/p&gt;

&lt;p&gt;C'est probablement ce qu'on perd quand on délègue la mesure, et ce qu'on regagne en prenant trois heures un samedi pour coder soi-même ce qui nous regarde chaque matin.&lt;/p&gt;

&lt;p&gt;Il faudra que je retourne voir Antoine avec le chiffre. Pas pour le convaincre. Pour avoir la méthode qui tient, le jour où il posera la question une dernière fois.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier dans ton projet
&lt;/h2&gt;

&lt;p&gt;Snippets complets (pattern &lt;code&gt;consolidate&lt;/code&gt;, garde-fou cron, schéma &lt;code&gt;valorisation_snapshots&lt;/code&gt;) dans le repo compagnon de la série, licence MIT : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Trois gestes directement applicables si tu pilotes un outil interne :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Un garde-fou sur tout compteur automatique qui entre dans un calcul de valeur.&lt;/strong&gt; Le snippet Slack de vingt lignes ci-dessus en est l'exemple minimal : détecter les bumps anormaux avant qu'ils soient encaissés comme « progression »&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Une structure de consolidation à plusieurs dimensions sommées&lt;/strong&gt; (pattern &lt;code&gt;consolidate(dims)&lt;/code&gt;), plutôt qu'une métrique unique. Le détail des quatre dimensions que j'utilise est traité dans l'article suivant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un snapshot daté en base&lt;/strong&gt; (&lt;code&gt;valorisation_snapshots&lt;/code&gt; avec &lt;code&gt;snapshot_date UNIQUE&lt;/code&gt;) qui te donne un historique défendable, auditable trois mois plus tard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et une discipline : &lt;strong&gt;si tu ne peux pas expliquer ton chiffre de valorisation à un tiers en dix minutes avec des sources traçables, ton instrument ne tient pas&lt;/strong&gt;. Peu importe qu'il soit beau.&lt;/p&gt;

&lt;p&gt;Et vous, comment mesurez-vous la valeur de votre outil interne ? Je lis les commentaires.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/valorisation/&lt;/code&gt;&lt;/a&gt; — le pattern &lt;code&gt;consolidate(dims)&lt;/code&gt; et le garde-fou Slack de 20 lignes sur le compteur de LOC, licence MIT.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>indiehackers</category>
      <category>career</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>I added 20 lines of code to stop my ERP from lying to me</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Wed, 29 Apr 2026 08:33:56 +0000</pubDate>
      <link>https://forem.com/michelfaure/i-added-20-lines-of-code-to-stop-my-erp-from-lying-to-me-ohd</link>
      <guid>https://forem.com/michelfaure/i-added-20-lines-of-code-to-stop-my-erp-from-lying-to-me-ohd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5c3z1fmtxms2vs97dru.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5c3z1fmtxms2vs97dru.png" alt="Comic strip — Michel sees a +9,318 line bump on his dashboard, hesitates, builds a watcher, gets the verdict " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook
&lt;/h2&gt;

&lt;p&gt;April 14th, 6:47 AM. My dashboard proudly announces a jump of &lt;strong&gt;9,318 lines&lt;/strong&gt; since yesterday. Of those 9,318, there are &lt;strong&gt;5,037&lt;/strong&gt; that come from a SQL dump of already existing migrations. A technical export, not a line of new work. And yet the counter climbs, the valuation gauge shifts, and the "100K lines" achievement blinks green. I look at it for five seconds, coffee in hand. I understand that my own tool is lying to me with my consent. Worse: it's been doing it for three weeks, and I knew.&lt;/p&gt;

&lt;p&gt;A few days earlier, Antoine had dropped by my office at a quarter to eight, hand on the doorframe. The former director, seventy-three years old, retiring in September. He didn't sit down. &lt;em&gt;« Michel, combien vaut la maison aujourd'hui, dis-moi ? »&lt;/em&gt; — &lt;em&gt;Michel, what is the house worth today, tell me?&lt;/em&gt; I served him back a sentence that said nothing. &lt;em&gt;« Évidemment. Bon, on avance. »&lt;/em&gt; — &lt;em&gt;Right. Let's move on.&lt;/em&gt; He walked out. The question stayed.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you have 30 seconds.&lt;/strong&gt; Measuring the value of internal software with &lt;code&gt;lines × day-rate&lt;/code&gt; produces a number that diverges from reality as AI drives down the cost of writing. This article explains why I coded my own valuation instrument rather than delegate to a firm, the economic thesis that justifies it (a &lt;em&gt;singular good&lt;/em&gt; needs a &lt;em&gt;judgment device&lt;/em&gt;), and the twenty-line guardrail that keeps my counter from lying. Useful if you run internal software without a market price.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I run a ceramics school in Paris and the greater Paris area, six sites, several hundred students. For twenty-nine days, I've been coding — alone, with Claude Code — the business ERP that replaces our stack of tools. The system is called Rembrandt. At the time of writing, it contains &lt;strong&gt;91,000 lines&lt;/strong&gt; of TypeScript, &lt;strong&gt;377 commits&lt;/strong&gt; over four weeks, &lt;strong&gt;16 documented architecture decisions&lt;/strong&gt;. I'm not a developer by training.&lt;/p&gt;

&lt;p&gt;An object like this never meets its price. No one buys a vertical ERP for a six-site art school on a market that doesn't exist. And production cost doesn't say much anymore either, because it's been divided by ten in eighteen months and keeps falling. In that void between a price that doesn't exist and a cost that no longer means anything, some measure has to stand in for a compass. By default, it's the line counter multiplied by a senior day-rate. Everyone knows the equation. It's seductive because it gives you a number, and a number makes the object exist as an asset rather than a side-project.&lt;/p&gt;

&lt;p&gt;For three weeks, I watched my dashboard climb with that equation in my gut. Until the morning of April 14th.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detour through the outside
&lt;/h2&gt;

&lt;p&gt;One Monday, in a Paris meeting room, we had signed with a well-known European ERP vendor. Annual licenses, a five-figure consulting package, tacit renewal. Everyone was smiling. What no one read out loud was the billing grid for custom developments: &lt;strong&gt;per line of code produced&lt;/strong&gt;. Technical annex, page 14. One line = one unit of value. We initialed.&lt;/p&gt;

&lt;p&gt;Three days later, rereading the contract in my office, I understood — a little late — that the metric produces the code as much as it measures it: when you pay per line, you receive lines. I called the vendor. I asked where the prepaid scope ended and where billing-per-line started. The answer was polite and circular. To this day the vendor refuses any refund and the negotiation is still open.&lt;/p&gt;

&lt;p&gt;It was the following Saturday, five days after signing, that I opened Claude Code for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weekend that flipped
&lt;/h2&gt;

&lt;p&gt;I'm not telling you about that Saturday for the sake of narrative. I'm telling you because it contains, in seed form, the mistake I was about to make against myself.&lt;/p&gt;

&lt;p&gt;I flipped &lt;strong&gt;because the LOC metric no longer held at the vendor&lt;/strong&gt;. A line of code billed as a unit of value, in a world where writing a line costs ten times less than it did two years ago. It doesn't take much reflection to see that this unit no longer holds on a vendor's side.&lt;/p&gt;

&lt;p&gt;Forty-eight hours later, I had something running. A Supabase schema, three Next.js routes, a working authentication page. Nothing spectacular. Just proof that the alternative existed, and that it fit in a weekend.&lt;/p&gt;

&lt;p&gt;It takes a little more to understand that you're applying to yourself the very metric you buried on the vendor's side. And yet that's exactly what my dashboard had been doing for twenty-one days, with my blessing. &lt;code&gt;lines_total × 15 €&lt;/code&gt; somewhere deep inside a function, and a gauge that climbed on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three drifts the counter can't see
&lt;/h2&gt;

&lt;p&gt;The first drift is the simplest. Production cost falls, the counter rises, the gap widens mechanically. By 2028, I could display 200,000 lines for a real cost of a few tens of thousands of euros. No accountant would sign off on that without raising an eyebrow. No buyer would pay that without an audit. The metric lies louder and louder, and it lies all the more loudly the longer you let it rise.&lt;/p&gt;

&lt;p&gt;The second drift is more subtle. Of the 91,000 lines, about 10,000 do routine CRUD on contacts and forms, replaceable in one morning by a SaaS at 100 euros a month. Other bundles of 10,000 lines encode the logic of four catch-up periods per year across six sites with Qualiopi certification rules that no one else has ever needed to formalize. Same volume, real values incomparable. The counter sees bytes where it should see the commoditizable and the singular separately.&lt;/p&gt;

&lt;p&gt;The third drift is the one that tipped me over. Rembrandt's real patrimony is not in the code. It's in about 3,000 historicized contacts, 5,000 qualified leads, 800 active enrollments, three years of reconciled financial history, and sixteen architecture decisions that crystallize why we do things this way and not otherwise. None of that weighs a single line of code. All of that weighs a significant share of what someone would pay to take the tool over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guardrail that settled it
&lt;/h2&gt;

&lt;p&gt;The day after April 14th, I added a twenty-line guardrail to the snapshot cron. The idea is simple: any abnormal bump in &lt;code&gt;lines_total&lt;/code&gt; must produce a warning before being cashed in as "progress".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/compute-valorisation-donnees/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valorisation_snapshots&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lines_total&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="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;snapshot_date&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="na"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;last7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last7&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;lines_total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines_total&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`:warning: abnormal bump lines_total: +&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`(7-day avg ~&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;). `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Verify before counting as value.`&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 isn't sophisticated. It's twenty lines of TypeScript that call a Slack webhook. But those twenty lines say something the previous twenty-one days weren't saying: &lt;strong&gt;an automatic counter that feeds into a value calculation must have a watcher&lt;/strong&gt;. Without a watcher, the metric becomes an oracle that believes itself. That's exactly what had happened with the commercial vendor. That's exactly what I was about to do to myself.&lt;/p&gt;

&lt;p&gt;I was also thinking about Antoine as I wrote this guardrail. He won't ask twice, and I don't want to hand him a number I haven't built myself. &lt;em&gt;« Vous êtes sûr ? »&lt;/em&gt; — &lt;em&gt;Are you sure?&lt;/em&gt; — is a short question that demands, behind it, a method that can stand on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The judgment device
&lt;/h2&gt;

&lt;p&gt;There's an economic thesis, discreet but useful, that says &lt;em&gt;singular goods&lt;/em&gt; — those without a market because they are unique and judged qualitatively rather than compared quantitatively — need a &lt;strong&gt;judgment device&lt;/strong&gt; to circulate, defend themselves, and be valued. Lucien Karpik formalized it for wines, books, doctors. It applies word for word to a custom ERP. No market produces its price. It's the device that produces its discussable value.&lt;/p&gt;

&lt;p&gt;What is at stake, then, in coding one's own valuation module, is not decorative. The instrument doesn't observe a pre-existing value that would be lying around, ready to be read. It &lt;strong&gt;manufactures the value as defensible&lt;/strong&gt;: every euro it displays must be justifiable by a transparent method and a traceable source. That's what makes the object defensible before an accountant, a tax administration, a potential buyer. Without the device, there is no value — there's a director's gut feeling who happens to have coded a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in code
&lt;/h2&gt;

&lt;p&gt;In concrete terms, the &lt;code&gt;valorisation&lt;/code&gt; module fits into a snapshots table and four dimension tables. The heart of the consolidated API looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/valorisation/compute.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Dimension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;donnees&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strategique&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;        &lt;span class="c1"&gt;// table or method of origin&lt;/span&gt;
  &lt;span class="na"&gt;refreshed_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;  &lt;span class="c1"&gt;// ISO date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;consolidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Dimension&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;present&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dims&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;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;value_low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;low&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="na"&gt;value_high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&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="na"&gt;dims_used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;present&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&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="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;Three things this snippet says clearly. &lt;strong&gt;We sum&lt;/strong&gt; the dimensions, we don't take the max, we don't weight. &lt;strong&gt;We keep track&lt;/strong&gt; of which dimensions were used in each snapshot, so we can later explain why an interval moved. &lt;strong&gt;We accept null&lt;/strong&gt;: if a dimension isn't yet instrumented, it doesn't break the calculation, it steps aside honestly.&lt;/p&gt;

&lt;p&gt;The detail of the four dimensions deserves its own article, and that's the next one in the series. Here I'm only trying to say what I understood on the morning of April 14th. A measurement instrument you give yourself is not one more dashboard. It's the gesture by which a priceless object becomes an asset you can talk about. As long as the instrument is wrong, the asset remains a side-project telling itself stories. When the instrument begins to hold, the object begins to exist.&lt;/p&gt;

&lt;p&gt;That's probably what you lose when you delegate the measurement, and what you regain by spending three hours on a Saturday coding yourself what stares at you every morning.&lt;/p&gt;

&lt;p&gt;I'll have to go back to Antoine with the number. Not to convince him. To have the method that holds, the day he asks the question one last time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy into your own project
&lt;/h2&gt;

&lt;p&gt;Full snippets (the &lt;code&gt;consolidate&lt;/code&gt; pattern, the cron guardrail, the &lt;code&gt;valorisation_snapshots&lt;/code&gt; schema) in the series' companion repo, MIT license: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Three directly applicable moves if you run internal software:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A guardrail on any automatic counter that feeds a value calculation.&lt;/strong&gt; The twenty-line Slack snippet above is the minimal example: detect abnormal bumps before they are cashed in as "progress"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A consolidation structure summing several dimensions&lt;/strong&gt; (the &lt;code&gt;consolidate(dims)&lt;/code&gt; pattern), rather than a single metric. The detail of the four dimensions I use is covered in the next article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dated snapshot in the database&lt;/strong&gt; (&lt;code&gt;valorisation_snapshots&lt;/code&gt; with &lt;code&gt;snapshot_date UNIQUE&lt;/code&gt;) that gives you a defensible history, auditable three months later&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And a discipline: &lt;strong&gt;if you can't explain your valuation figure to a third party in ten minutes with traceable sources, your instrument doesn't hold&lt;/strong&gt;. It doesn't matter how beautiful it is.&lt;/p&gt;

&lt;p&gt;And you — how do you measure the value of your internal tool? I read the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/valorisation/&lt;/code&gt;&lt;/a&gt; — the &lt;code&gt;consolidate(dims)&lt;/code&gt; pattern and the 20-line Slack guardrail, MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>indiehackers</category>
      <category>career</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>4 incidents, 4 règles : comment mon CLAUDE.md s'est écrit tout seul</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Tue, 28 Apr 2026 08:39:43 +0000</pubDate>
      <link>https://forem.com/michelfaure/4-incidents-4-regles-comment-mon-claudemd-sest-ecrit-tout-seul-dpl</link>
      <guid>https://forem.com/michelfaure/4-incidents-4-regles-comment-mon-claudemd-sest-ecrit-tout-seul-dpl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Si tu as 30 secondes.&lt;/strong&gt; Un &lt;code&gt;CLAUDE.md&lt;/code&gt; efficace ne documente pas, il &lt;strong&gt;contraint&lt;/strong&gt; — chaque règle répond à une fois où l'agent s'est trompé. Cet article donne la structure à quatre couches que j'utilise pour un ERP de 91 000 lignes (CLAUDE.md racine, AGENTS.md, &lt;code&gt;.claude/rules/&lt;/code&gt; par module, skill auto-invoqué), quatre règles opérantes tirées d'incidents datés, et une discipline tenable : &lt;strong&gt;écrire l'interdit avant la bonne pratique&lt;/strong&gt;. Utile si tu pilotes du code avec Claude Code au quotidien et que tu vois ton agent dériver.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Pourquoi pas juste un README
&lt;/h2&gt;

&lt;p&gt;On me demande pourquoi je ne mets pas simplement dans le README ce qui est dans &lt;code&gt;CLAUDE.md&lt;/code&gt;. Les deux fichiers n'ont pas le même destinataire. Le README s'adresse à un humain qui le lira une fois, au démarrage, et s'en souviendra selon ses moyens. Le &lt;code&gt;CLAUDE.md&lt;/code&gt; s'adresse à un agent qui le relit à chaque session, qui n'a pas de mémoire entre deux sessions, et qui prendra chaque phrase au pied de la lettre. Le README documente, le &lt;code&gt;CLAUDE.md&lt;/code&gt; contraint. Pas de paragraphes introductifs, pas de storytelling. Des règles denses formulées pour être lues hors contexte, avec une séparation stricte entre ce qui est autorisé, ce qui est interdit, et ce qui demande une validation humaine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version initiale, quarante lignes de naïveté
&lt;/h2&gt;

&lt;p&gt;Le premier &lt;code&gt;CLAUDE.md&lt;/code&gt; de Rembrandt, posé le 21 mars 2026, tenait dans une page d'écran. Stack, commandes, arborescence, quelques conventions évidentes du type « Server Components par défaut ». Ce qui me frappe en le relisant, ce n'est pas ce qu'il contient, mais ce qu'il ne contient pas. Rien sur ce que l'agent allait se tromper à faire les jours suivants. Nous écrivons ce que nous savons déjà, alors que la valeur du fichier vient précisément de ce que nous ne savons pas encore. Les règles utiles ne pouvaient pas être formulées au jour 1, parce qu'elles ont été produites par des incidents qui n'avaient pas encore eu lieu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quatre incidents, quatre règles
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Server Component + onClick, le crash silencieux
&lt;/h3&gt;

&lt;p&gt;Jour 4. Catherine passe la tête dans le bureau. « Michel, j'ai cliqué sur le bouton d'émargement et rien. Pas d'erreur, pas de rouge, rien. » Elle ne me dit pas « ça plante », elle me dit « rien ne se passe », ce qui pour un bouton est précisément pire.&lt;/p&gt;

&lt;p&gt;Je rouvre la page. Le build TypeScript est vert, Turbopack ne remonte rien, le crash n'apparaît qu'au rendu serveur en prod, avec un message sibyllin, &lt;code&gt;Event handlers cannot be passed to Client Component props&lt;/code&gt;, ERROR 3637204658. Nous avons tous tendance à chercher la cause dans le composant qui crashe, et c'est là que le piège se referme. L'erreur vient d'un &lt;code&gt;&amp;lt;select onChange={() =&amp;gt; {}}&amp;gt;&lt;/code&gt; dans le Server Component parent, pas dans le composant client qu'on suspecte. La phrase de Catherine a produit la règle, ajoutée au &lt;code&gt;CLAUDE.md&lt;/code&gt; dès le lendemain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Server Components par défaut, &lt;span class="sb"&gt;`'use client'`&lt;/span&gt; uniquement si état interactif requis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Elle paraît anodine écrite comme ça. Pourtant elle porte la cicatrice d'un bouton qui n'a pas répondu à Catherine.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. RLS + mauvais client Supabase, zéro ligne sans erreur
&lt;/h3&gt;

&lt;p&gt;25 mars. Françoise m'appelle du bureau d'à côté, elle crie. « Bon. Tes inscriptions sur Maisons-Laffitte, il y en a combien ? Moi j'en vois zéro. » J'ouvre la même page sur mon poste, je vois zéro aussi. « Il y a un moment où il faut y aller, parce que là je peux pas pointer. »&lt;/p&gt;

&lt;p&gt;Nous venons d'activer RLS sur dix-huit tables. Les policies sont écrites, testées en SQL direct, tout passe. Déploiement en prod. Toutes les pages affichent zéro ligne. Pas d'exception, pas de 500, pas de log d'erreur. Simplement zéro, ce qui est précisément ce qui rend le bug dangereux, parce que Françoise ne voit rien à corriger, elle voit une école vide. Le client SSR avec la anon key est bien en place, le cookie d'auth est bien transmis, mais le JWT ne passe plus. La requête tombe en rôle &lt;code&gt;anon&lt;/code&gt;, aucune policy ne matche, résultat vide et silencieux. La règle inscrite dans &lt;code&gt;CLAUDE.md&lt;/code&gt; et dans le skill &lt;code&gt;rembrandt-conventions&lt;/code&gt; qui s'invoque automatiquement sur tout code ERP, c'est que les Server Components utilisent &lt;code&gt;createSupabaseAdmin&lt;/code&gt;, jamais &lt;code&gt;createSupabaseServer&lt;/code&gt;. L'auth est déjà vérifiée par le &lt;code&gt;proxy.ts&lt;/code&gt; en amont, la clé service_role n'atteint jamais le client. Françoise a retrouvé son pointage le lendemain.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Build vert surestimé, la règle qui contraint l'agent à se prouver
&lt;/h3&gt;

&lt;p&gt;10 avril, trois heures trente du matin. La refonte émargement tourne depuis huit heures, l'écran me renvoie pour la quatrième fois « build vert, tous les checks passent ». Je n'y crois plus. Je bascule dans le terminal en local, je relance &lt;code&gt;pnpm build&lt;/code&gt; à la main, et la sortie renvoie &lt;code&gt;error TS2307: Cannot find name 'QRCodeSVG'&lt;/code&gt;. Trois lignes plus bas, &lt;code&gt;Property 'isSeancePassed' does not exist&lt;/code&gt;. Et la colonne &lt;code&gt;motif_absence&lt;/code&gt; ajoutée en DB la veille sans régénération des types. Quatre fois « vert », quatre fois faux.&lt;/p&gt;

&lt;p&gt;Ce n'est pas un incident technique, c'est une dérive comportementale. L'agent n'a pas menti, il a probablement lancé la commande sur un état intermédiaire, ou il a lu un cache LSP obsolète. La règle qu'il fallait écrire n'était pas technique, elle portait sur la manière de prouver le build.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Pour toute modif, copier la sortie brute de &lt;span class="sb"&gt;`pnpm build`&lt;/span&gt; dans le rapport.
  Si &lt;span class="sb"&gt;`error TS`&lt;/span&gt; ou &lt;span class="sb"&gt;`Type error`&lt;/span&gt; apparaît, le build n'est pas vert.
&lt;span class="p"&gt;-&lt;/span&gt; Pour revert ou refactor, &lt;span class="sb"&gt;`grep -rn "mot_cle" app/ lib/ components/`&lt;/span&gt;
  avec zéro occurrence comme preuve.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sans cette contrainte, l'agent surestime toujours. Avec, il montre ses cartes.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. 1 inscription = N places, le contre-modèle métier
&lt;/h3&gt;

&lt;p&gt;Cet incident-là, je l'ai raconté ailleurs dans la série, le matin où Françoise compare le chiffre du dashboard à son Excel et lâche son « Oui bah c'est pas ça ». Je le rappelle ici parce qu'il est la mère de toutes les règles métier du &lt;code&gt;CLAUDE.md&lt;/code&gt;. La table s'appelle &lt;code&gt;inscriptions&lt;/code&gt;, le nom est explicite, et l'agent en a déduit, raisonnablement, que chaque ligne représentait une inscription commerciale. Il a tort. La table stocke des &lt;strong&gt;places&lt;/strong&gt;, une ligne par contact et par cours, index UNIQUE &lt;code&gt;(contact_id, cours_id)&lt;/code&gt;. Un élève inscrit à deux cours occupe deux lignes, et un &lt;code&gt;COUNT(*) FROM inscriptions&lt;/code&gt; compte des places, pas des élèves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; 1 inscription commerciale = N places cours
&lt;span class="p"&gt;-&lt;/span&gt; « Nombre d'élèves » → COUNT(DISTINCT contact_id)
&lt;span class="p"&gt;-&lt;/span&gt; « Places d'un cours » → COUNT(&lt;span class="err"&gt;*&lt;/span&gt;) WHERE cours_id=X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le vocabulaire métier n'est pas intuitif, et l'agent ne peut pas le deviner. Le &lt;code&gt;CLAUDE.md&lt;/code&gt; est le seul endroit où le contre-modèle peut être posé avant que l'agent ne régénère la mauvaise intuition à chaque session.&lt;/p&gt;

&lt;h2&gt;
  
  
  La structure actuelle
&lt;/h2&gt;

&lt;p&gt;Quatre fichiers travaillent ensemble. Le &lt;code&gt;CLAUDE.md&lt;/code&gt; racine porte la stack, les commandes, les conventions transversales, l'arborescence des modules et les zones interdites (&lt;code&gt;.env.local&lt;/code&gt;, &lt;code&gt;lib/supabase-admin.ts&lt;/code&gt;, policies RLS existantes, &lt;code&gt;/api/cron/&lt;/code&gt;, tables critiques). Cent vingt lignes denses, aucune narration, pas un mot de trop.&lt;/p&gt;

&lt;p&gt;L'&lt;code&gt;AGENTS.md&lt;/code&gt; tient en cinq lignes brutales qui disent à l'agent de ne pas se fier à sa mémoire pour Next.js 16, et de lire le guide dans &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; avant d'écrire la moindre route. Ce fichier a réglé plus de bugs à lui seul que n'importe quelle règle longue.&lt;/p&gt;

&lt;p&gt;Le &lt;code&gt;.claude/rules/finance.md&lt;/code&gt; rassemble les règles verticales du module Finance, sorties du CLAUDE.md parce qu'elles ne concernent qu'un seul périmètre. Modèle CASH, exonérations TVA AFDAS/FORDIP, GL 512x qui ment, prorata TVA 43 % FY26. Un agent qui ne touche pas à &lt;code&gt;/app/finance/&lt;/code&gt; ne les charge pas.&lt;/p&gt;

&lt;p&gt;Le skill &lt;code&gt;rembrandt-conventions&lt;/code&gt;, enfin, s'auto-invoque sur tout code ERP. Il consolide les règles avec pointeurs vers les mémoires &lt;code&gt;feedback_*.md&lt;/code&gt; qui racontent l'incident source. Quand une règle semble fausse, on remonte à l'incident, pas à l'opinion. Mélanger les règles verticales dans le &lt;code&gt;CLAUDE.md&lt;/code&gt; racine noierait l'agent sous du contexte non pertinent à chaque session. La sédimentation par couches permet à chaque tâche de charger exactement ce dont elle a besoin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que j'ai appris en quatre semaines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Écrire l'interdit avant la bonne pratique.&lt;/strong&gt; Une règle positive du type « utilisez Server Components par défaut » est lue et oubliée. Une règle négative du type « ne jamais désactiver la 2FA de inscription@, ça casse l'app password Gmail » est lue et retenue parce qu'elle porte sa conséquence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citer l'incident.&lt;/strong&gt; Numéro d'erreur, date, ce qui a crashé. La règle devient opposable. L'agent peut vérifier, le lecteur humain aussi. Les règles abstraites se dissolvent, les règles tracées tiennent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Séparer le général du vertical.&lt;/strong&gt; Ce qui vaut partout va dans le &lt;code&gt;CLAUDE.md&lt;/code&gt; racine. Ce qui vaut pour un module va dans &lt;code&gt;.claude/rules/&amp;lt;module&amp;gt;.md&lt;/code&gt;. Ce qui vaut pour toute la culture projet et peut servir à d'autres agents va dans un skill. Trois régimes, trois fichiers. Melvin Conway l'énonçait en 1968 — &lt;em&gt;les systèmes que vous concevez reflètent la structure de l'organisation qui les conçoit&lt;/em&gt;. Un &lt;code&gt;CLAUDE.md&lt;/code&gt; en couches qui reflète la structure réelle du projet — général, module, culture — est le versant logiciel de cette loi, et c'est précisément pourquoi il tient : l'agent, quand il lit, reçoit le projet dans la forme même qui l'a produit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le CLAUDE.md n'est jamais fini, et c'est précisément pour ça qu'il marche.&lt;/strong&gt; Un fichier figé au jour 1 n'aurait jamais couvert les quatre incidents racontés plus haut. Le fichier vivant, lui, les a tous intégrés. Nous pourrions dire qu'il est l'empreinte des chocs, et que la qualité d'un projet avec Claude Code se mesure autant à ce qui est dans ce fichier qu'au code lui-même.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier dans ton projet
&lt;/h2&gt;

&lt;p&gt;Un template à quatre couches (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.claude/rules/module.md&lt;/code&gt;) dans le repo compagnon de la série, licence MIT : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/claude-md" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Quatre éléments réutilisables, indépendants de ma stack :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La structure à quatre couches&lt;/strong&gt; — un &lt;code&gt;CLAUDE.md&lt;/code&gt; racine (général, court), un &lt;code&gt;AGENTS.md&lt;/code&gt; (si tu utilises plusieurs agents Claude), un dossier &lt;code&gt;.claude/rules/&amp;lt;module&amp;gt;.md&lt;/code&gt; (règles verticales), et un skill auto-invoqué par périmètre. Chaque niveau charge exactement ce dont une tâche a besoin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Le format de règle négative&lt;/strong&gt; : &lt;code&gt;« ne jamais X, parce que Y a crashé le DATE »&lt;/code&gt;. Portée explicite, incident cité, date datée. Plus opposable qu'une règle positive&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un numéro d'incident par règle&lt;/strong&gt; : même approximatif (date, message d'erreur, fichier). La règle devient vérifiable, traçable, discutable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Le versioning&lt;/strong&gt; : le &lt;code&gt;CLAUDE.md&lt;/code&gt; est dans le repo, il suit les migrations. Une régression de règle se remarque dans un &lt;code&gt;git log&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et une discipline : &lt;strong&gt;lire son propre &lt;code&gt;CLAUDE.md&lt;/code&gt; tous les 15 jours&lt;/strong&gt;. Si une règle n'a pas été convoquée depuis un mois, soit le problème est résolu (on peut l'archiver), soit la règle est trop abstraite pour s'appliquer (on la réécrit). Un fichier qui dort n'aide pas l'agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  À vous
&lt;/h2&gt;

&lt;p&gt;Si vous avez un &lt;code&gt;CLAUDE.md&lt;/code&gt; qui tient la route, quelle est la règle que vous avez mis le plus de temps à y inscrire, et quel incident l'a produite ? Je suis preneur. Les meilleurs patterns que j'ai vus viennent toujours d'une cicatrice.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/claude-md" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/claude-md/&lt;/code&gt;&lt;/a&gt; — le template à 4 couches (CLAUDE.md, AGENTS.md, règle verticale, fichier feedback), licence MIT.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>4 incidents, 4 rules: how my CLAUDE.md wrote itself</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Tue, 28 Apr 2026 08:39:42 +0000</pubDate>
      <link>https://forem.com/michelfaure/4-incidents-4-rules-how-my-claudemd-wrote-itself-o3n</link>
      <guid>https://forem.com/michelfaure/4-incidents-4-rules-how-my-claudemd-wrote-itself-o3n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you have 30 seconds.&lt;/strong&gt; An effective &lt;code&gt;CLAUDE.md&lt;/code&gt; doesn't document, it &lt;strong&gt;constrains&lt;/strong&gt; — each rule answers a time the agent got it wrong. This article gives the four-layer structure I use for a 91,000-line ERP (root &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, per-module &lt;code&gt;.claude/rules/&lt;/code&gt;, auto-loaded skill), four operational rules drawn from dated incidents, and one sustainable discipline: &lt;strong&gt;write the forbidden before the best practice&lt;/strong&gt;. Useful if you drive code with Claude Code daily and see your agent drifting.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why not just a README
&lt;/h2&gt;

&lt;p&gt;I'm asked why I don't just put in the README what's in &lt;code&gt;CLAUDE.md&lt;/code&gt;. The two files don't have the same audience. The README speaks to a human who will read it once at onboarding and remember as best they can. The &lt;code&gt;CLAUDE.md&lt;/code&gt; speaks to an agent that rereads it at every session, has no memory between sessions, and will take each sentence at face value. The README documents, the &lt;code&gt;CLAUDE.md&lt;/code&gt; constrains. No introductory paragraphs, no storytelling. Dense rules formulated to be read out of context, with a strict separation between what is allowed, what is forbidden, and what requires human validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initial version, forty lines of naiveté
&lt;/h2&gt;

&lt;p&gt;The first &lt;code&gt;CLAUDE.md&lt;/code&gt; for Rembrandt, dropped on March 21st, 2026, fit in one screen. Stack, commands, tree structure, a few obvious conventions like "Server Components by default". What strikes me rereading it isn't what it contains, but what it doesn't. Nothing about what the agent was going to get wrong in the days that followed. We write what we already know, when the file's value precisely comes from what we don't know yet. The useful rules couldn't have been formulated on day 1, because they were produced by incidents that hadn't yet happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four incidents, four rules
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Server Component + onClick, the silent crash
&lt;/h3&gt;

&lt;p&gt;Day 4. Catherine leans into the office. &lt;em&gt;« Michel, j'ai cliqué sur le bouton d'émargement et rien. Pas d'erreur, pas de rouge, rien. »&lt;/em&gt; — &lt;em&gt;Michel, I clicked the attendance button and nothing. No error, no red, nothing.&lt;/em&gt; She doesn't tell me "it's crashing," she tells me "nothing happens," which for a button is precisely worse.&lt;/p&gt;

&lt;p&gt;I reopen the page. TypeScript build green, Turbopack reports nothing, the crash only shows up at server rendering in production, with a sibylline message, &lt;code&gt;Event handlers cannot be passed to Client Component props&lt;/code&gt;, ERROR 3637204658. We all tend to look for the cause in the component that crashes, and that's where the trap closes. The error comes from a &lt;code&gt;&amp;lt;select onChange={() =&amp;gt; {}}&amp;gt;&lt;/code&gt; in the parent Server Component, not in the client component we suspect. Catherine's sentence produced the rule, added to the &lt;code&gt;CLAUDE.md&lt;/code&gt; the next day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Server Components by default, &lt;span class="sb"&gt;`'use client'`&lt;/span&gt; only if interactive state is required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks innocuous written like that. Yet it bears the scar of a button that didn't answer Catherine.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. RLS + wrong Supabase client, zero rows without error
&lt;/h3&gt;

&lt;p&gt;March 25th. Françoise calls me from the next office, she's shouting. &lt;em&gt;« Bon. Tes inscriptions sur Maisons-Laffitte, il y en a combien ? Moi j'en vois zéro. »&lt;/em&gt; — &lt;em&gt;Right. Your enrollments on Maisons-Laffitte site, how many are there? Because I see zero.&lt;/em&gt; I open the same page on my machine, I see zero too. &lt;em&gt;« Il y a un moment où il faut y aller, parce que là je peux pas pointer. »&lt;/em&gt; — &lt;em&gt;There comes a point where you have to sort this out, because right now I can't do attendance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We had just turned on RLS on eighteen tables. Policies written, tested in direct SQL, everything passing. Prod deploy. Every page shows zero rows. No exception, no 500, no error log. Just zero, which is precisely what makes the bug dangerous, because Françoise doesn't see anything to fix, she sees an empty school. The SSR client with the anon key is in place, the auth cookie is transmitted, but the JWT no longer passes. The query falls back to the &lt;code&gt;anon&lt;/code&gt; role, no policy matches, result empty and silent. The rule written into &lt;code&gt;CLAUDE.md&lt;/code&gt; and into the &lt;code&gt;rembrandt-conventions&lt;/code&gt; skill that auto-loads on any ERP code is that Server Components use &lt;code&gt;createSupabaseAdmin&lt;/code&gt;, never &lt;code&gt;createSupabaseServer&lt;/code&gt;. Auth is already verified by the upstream &lt;code&gt;proxy.ts&lt;/code&gt;, the service_role key never reaches the client. Françoise got her attendance back the next day.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Overstated green build, the rule that forces the agent to prove itself
&lt;/h3&gt;

&lt;p&gt;April 10th, 3:30 AM. The attendance overhaul has been running for eight hours, the screen tells me for the fourth time "build green, all checks pass." I don't believe it anymore. I switch to the local terminal, I run &lt;code&gt;pnpm build&lt;/code&gt; by hand, and the output returns &lt;code&gt;error TS2307: Cannot find name 'QRCodeSVG'&lt;/code&gt;. Three lines down, &lt;code&gt;Property 'isSeancePassed' does not exist&lt;/code&gt;. And the &lt;code&gt;motif_absence&lt;/code&gt; column added to the DB the day before without regenerating the types. Four times "green", four times false.&lt;/p&gt;

&lt;p&gt;This isn't a technical incident, it's a behavioral drift. The agent didn't lie, it probably ran the command on an intermediate state, or read a stale LSP cache. The rule that needed writing wasn't technical, it was about how to prove the build.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; For any change, paste the raw output of &lt;span class="sb"&gt;`pnpm build`&lt;/span&gt; in the report.
  If &lt;span class="sb"&gt;`error TS`&lt;/span&gt; or &lt;span class="sb"&gt;`Type error`&lt;/span&gt; appears, the build is not green.
&lt;span class="p"&gt;-&lt;/span&gt; For revert or refactor, &lt;span class="sb"&gt;`grep -rn "keyword" app/ lib/ components/`&lt;/span&gt;
  with zero occurrences as proof.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that constraint, the agent always overstates. With it, it shows its cards.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. 1 enrollment = N seats, the business counter-model
&lt;/h3&gt;

&lt;p&gt;This incident I told elsewhere in the series — the morning Françoise compares the dashboard number to her Excel and delivers her verdict. I bring it back here because it's the mother of all business rules in the &lt;code&gt;CLAUDE.md&lt;/code&gt;. The table is called &lt;code&gt;inscriptions&lt;/code&gt;, the name is explicit, and the agent deduced, reasonably, that each row represents a commercial enrollment. It is wrong. The table stores &lt;strong&gt;seats&lt;/strong&gt;, one row per contact per course, UNIQUE index &lt;code&gt;(contact_id, cours_id)&lt;/code&gt;. A student enrolled in two courses occupies two rows, and a &lt;code&gt;COUNT(*) FROM inscriptions&lt;/code&gt; counts seats, not students.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; 1 commercial enrollment = N course seats
&lt;span class="p"&gt;-&lt;/span&gt; "Number of students" → COUNT(DISTINCT contact_id)
&lt;span class="p"&gt;-&lt;/span&gt; "Seats in a course" → COUNT(&lt;span class="err"&gt;*&lt;/span&gt;) WHERE cours_id=X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Business vocabulary isn't intuitive, and the agent can't guess it. The &lt;code&gt;CLAUDE.md&lt;/code&gt; is the only place where the counter-model can be set down before the agent regenerates the wrong intuition at every session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The current structure
&lt;/h2&gt;

&lt;p&gt;Four files work together. The root &lt;code&gt;CLAUDE.md&lt;/code&gt; carries the stack, commands, transversal conventions, module tree and forbidden zones (&lt;code&gt;.env.local&lt;/code&gt;, &lt;code&gt;lib/supabase-admin.ts&lt;/code&gt;, existing RLS policies, &lt;code&gt;/api/cron/&lt;/code&gt;, critical tables). One hundred and twenty dense lines, no narration, not a word extra.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AGENTS.md&lt;/code&gt; fits in five blunt lines that tell the agent not to trust its memory for Next.js 16, and to read the guide in &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt; before writing a single route. That file has fixed more bugs on its own than any long rule.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.claude/rules/finance.md&lt;/code&gt; gathers the vertical rules of the Finance module, pulled out of the &lt;code&gt;CLAUDE.md&lt;/code&gt; because they only concern one perimeter. CASH model, VAT exemptions on professional training, bank ledger GL-512x inaccuracy, 43% VAT prorata FY26. An agent that doesn't touch &lt;code&gt;/app/finance/&lt;/code&gt; doesn't load them.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;rembrandt-conventions&lt;/code&gt; skill, finally, auto-invokes on all ERP code. It consolidates the rules with pointers to the &lt;code&gt;feedback_*.md&lt;/code&gt; memories that tell the source incident. When a rule looks wrong, you trace back to the incident, not to opinion. Mixing vertical rules into the root &lt;code&gt;CLAUDE.md&lt;/code&gt; would drown the agent in irrelevant context at every session. Layered sedimentation lets each task load exactly what it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned in four weeks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write the forbidden before the best practice.&lt;/strong&gt; A positive rule like "use Server Components by default" is read and forgotten. A negative rule like "never disable 2FA on inscription@, it breaks the Gmail app password" is read and retained because it carries its consequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cite the incident.&lt;/strong&gt; Error code, date, what crashed. The rule becomes opposable. The agent can verify, the human reader too. Abstract rules dissolve, traced rules hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate the general from the vertical.&lt;/strong&gt; What holds everywhere goes into the root &lt;code&gt;CLAUDE.md&lt;/code&gt;. What holds for one module goes into &lt;code&gt;.claude/rules/&amp;lt;module&amp;gt;.md&lt;/code&gt;. What holds for the whole project culture and can serve other agents goes into a skill. Three regimes, three files. Melvin Conway stated it in 1968 — &lt;em&gt;systems that you design reflect the organization that designs them&lt;/em&gt;. A layered &lt;code&gt;CLAUDE.md&lt;/code&gt; that reflects the real structure of the project — general, module, culture — is the software side of that law, and that's precisely why it holds: the agent, when it reads, receives the project in the very form that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CLAUDE.md is never finished, and that's precisely why it works.&lt;/strong&gt; A file frozen on day 1 would never have covered the four incidents told above. The living file has integrated them all. We could say it is the imprint of the shocks, and that a project's quality with Claude Code is measured as much by what is in this file as by the code itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy into your project
&lt;/h2&gt;

&lt;p&gt;A four-layer template (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.claude/rules/module.md&lt;/code&gt;) in the series' companion repo, MIT license: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/claude-md" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Four reusable elements, independent of my stack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The four-layer structure&lt;/strong&gt; — a root &lt;code&gt;CLAUDE.md&lt;/code&gt; (general, short), an &lt;code&gt;AGENTS.md&lt;/code&gt; (if you use several Claude agents), a &lt;code&gt;.claude/rules/&amp;lt;module&amp;gt;.md&lt;/code&gt; folder (vertical rules), and a per-perimeter auto-invoked skill. Each level loads exactly what a task needs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The negative rule format&lt;/strong&gt;: &lt;code&gt;"never do X, because Y crashed on DATE"&lt;/code&gt;. Explicit scope, cited incident, dated date. More opposable than a positive rule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One incident number per rule&lt;/strong&gt;: even approximate (date, error message, file). The rule becomes verifiable, traceable, discussable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versioning&lt;/strong&gt;: the &lt;code&gt;CLAUDE.md&lt;/code&gt; lives in the repo, it follows migrations. A rule regression shows up in a &lt;code&gt;git log&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one discipline: &lt;strong&gt;reread your own &lt;code&gt;CLAUDE.md&lt;/code&gt; every 15 days&lt;/strong&gt;. If a rule hasn't been invoked in a month, either the problem is solved (you can archive it), or the rule is too abstract to apply (you rewrite it). A file that sleeps doesn't help the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over to you
&lt;/h2&gt;

&lt;p&gt;If you have a &lt;code&gt;CLAUDE.md&lt;/code&gt; that holds up, what's the rule that took you the longest to write, and what incident produced it? I'm all ears. The best patterns I've seen always come from a scar.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/claude-md" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/claude-md/&lt;/code&gt;&lt;/a&gt; — the 4-layer template (CLAUDE.md, AGENTS.md, vertical rule, feedback file), MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>RLS Supabase en prod : quatre pièges qui silencent tes requêtes</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 27 Apr 2026 07:45:29 +0000</pubDate>
      <link>https://forem.com/michelfaure/rls-supabase-en-prod-quatre-pieges-qui-silencent-tes-requetes-3c8m</link>
      <guid>https://forem.com/michelfaure/rls-supabase-en-prod-quatre-pieges-qui-silencent-tes-requetes-3c8m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn39xzdovr4ygid6grpl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn39xzdovr4ygid6grpl.png" alt="Strip BD — Adèle bloquée sur l'onglet Messages par la RLS, Michel diagnostique en silence puis corrige la policy : « A row visible to whoever has the right. Not by me in service_role. »" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  « Tes inscriptions, il y en a combien ? Moi j'en vois zéro »
&lt;/h2&gt;

&lt;p&gt;Un mardi matin, je venais d'activer &lt;em&gt;RLS&lt;/em&gt; sur dix-huit tables de &lt;em&gt;Rembrandt&lt;/em&gt;, l'ERP de L'Atelier Palissy. Les &lt;em&gt;policies&lt;/em&gt; étaient écrites, testées en &lt;em&gt;SQL&lt;/em&gt; direct, tout passait. Déploiement en prod, café. Françoise m'appelle du bureau d'à côté, elle ne vient pas, elle crie depuis sa chaise. &lt;em&gt;« Bon. Tes inscriptions sur le site de Maisons-Laffitte, il y en a combien, dis-moi ? Moi j'en vois zéro. »&lt;/em&gt; J'ouvre la même page sur mon poste. Zéro aussi. Pas d'exception, pas de 500, pas de log d'erreur dans &lt;em&gt;Sentry&lt;/em&gt;. Simplement zéro ligne, ce qui est précisément ce qui rend ce bug dangereux : Françoise ne voit rien à corriger, elle voit une école vide.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Row Level Security&lt;/em&gt; est une des rares features &lt;em&gt;Postgres/Supabase&lt;/em&gt; qui peut casser ton application &lt;strong&gt;en silence&lt;/strong&gt;. Un mauvais réglage ne te renvoie pas d'erreur. Il te renvoie un ensemble vide, ou pire, un ensemble partiel qui passe le code sans l'alerter. J'ai passé quatre semaines à tomber sur quatre pièges distincts, à les nommer, à les documenter. Cet article les rassemble.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Si tu as 30 secondes.&lt;/strong&gt; &lt;em&gt;RLS&lt;/em&gt; bien configurée est le meilleur garde-fou de données que tu puisses poser sur une base Supabase. &lt;em&gt;RLS&lt;/em&gt; mal configurée est le pire bug parce qu'elle ne crie jamais. Les quatre pièges : mauvais client &lt;em&gt;Supabase&lt;/em&gt; côté &lt;em&gt;Server&lt;/em&gt;, &lt;em&gt;RPC SECURITY DEFINER&lt;/em&gt; ouvertes à &lt;em&gt;anon&lt;/em&gt;, &lt;em&gt;policies&lt;/em&gt; d'écriture sans &lt;em&gt;role check&lt;/em&gt;, bucket &lt;em&gt;Storage&lt;/em&gt; public oublié. Chacun a un symptôme silencieux — requête vide, endpoint public, écriture autorisée, fichier exposé — et une correction en cinq minutes une fois la cause trouvée. L'article donne les quatre symptômes et les quatre corrections.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Piège 1 — Le mauvais client côté Server Component
&lt;/h2&gt;

&lt;p&gt;C'est le piège qui a mis Françoise devant une école vide. &lt;em&gt;Supabase&lt;/em&gt; expose trois clients distincts, et leur différence ne se voit pas au premier regard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseBrowser()&lt;/code&gt; avec la &lt;em&gt;anon key&lt;/em&gt;, côté navigateur&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseServer()&lt;/code&gt; avec la &lt;em&gt;anon key&lt;/em&gt; plus le cookie d'auth, côté &lt;em&gt;Server Component&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseAdmin()&lt;/code&gt; avec la &lt;em&gt;service_role key&lt;/em&gt;, côté serveur, bypass &lt;em&gt;RLS&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Le piège : si tu utilises &lt;code&gt;createSupabaseServer()&lt;/code&gt; dans un &lt;em&gt;Server Component&lt;/em&gt; mais que le cookie d'auth ne transite pas correctement — &lt;em&gt;middleware&lt;/em&gt; mal configuré, &lt;em&gt;refresh token&lt;/em&gt; expiré, route &lt;em&gt;proxy&lt;/em&gt; qui reforme la requête —, le &lt;em&gt;JWT&lt;/em&gt; tombe à &lt;em&gt;anon&lt;/em&gt;. Aucune &lt;em&gt;policy&lt;/em&gt; ne matche pour un utilisateur &lt;em&gt;anon&lt;/em&gt;. La requête retourne zéro ligne. Pas d'erreur, parce que techniquement la requête est valide, &lt;em&gt;Postgres&lt;/em&gt; a juste trouvé que rien ne matche.&lt;/p&gt;

&lt;p&gt;La règle que j'ai fini par écrire dans mon &lt;code&gt;CLAUDE.md&lt;/code&gt; et dans un &lt;em&gt;skill&lt;/em&gt; auto-invoqué par l'agent : &lt;strong&gt;dans un &lt;em&gt;Server Component&lt;/em&gt;, utiliser &lt;code&gt;createSupabaseAdmin()&lt;/code&gt;, jamais &lt;code&gt;createSupabaseServer()&lt;/code&gt;&lt;/strong&gt;. L'authentification est déjà vérifiée en amont par le &lt;em&gt;middleware&lt;/em&gt; qui garde la route, la &lt;code&gt;service_role&lt;/code&gt; n'atteint jamais le navigateur, et les requêtes retournent ce qu'elles doivent retourner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Silencieusement vide si l'auth ne passe pas&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSupabaseServer&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;@/lib/supabase-server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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="c1"&gt;// data = [] sans erreur&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ L'auth est déjà vérifiée par le middleware, RLS bypassée&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSupabaseAdmin&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;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Piège 2 — Les fonctions RPC ouvertes à &lt;code&gt;anon&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Deuxième piège, plus vicieux parce qu'il te rend les données en sens inverse : tu n'as pas trop peu, tu as &lt;em&gt;trop&lt;/em&gt; de monde qui peut lire.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Supabase&lt;/em&gt; génère des endpoints REST pour toutes tes fonctions &lt;em&gt;Postgres&lt;/em&gt; déclarées en &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, et par défaut &lt;code&gt;PUBLIC&lt;/code&gt; a les droits d'exécution. Or &lt;code&gt;PUBLIC&lt;/code&gt; dans &lt;em&gt;Postgres&lt;/em&gt; inclut le rôle &lt;code&gt;anon&lt;/code&gt;, qui est le rôle utilisé quand quelqu'un tape un &lt;code&gt;curl&lt;/code&gt; sur ton endpoint sans &lt;em&gt;token&lt;/em&gt;. Autrement dit, tes fonctions de calcul — &lt;em&gt;pay_echeance_tx&lt;/em&gt;, &lt;em&gt;publier_planning_tx&lt;/em&gt;, &lt;em&gt;convertir_sd_tx&lt;/em&gt; — sont exposées par défaut à n'importe qui sur internet.&lt;/p&gt;

&lt;p&gt;J'ai découvert ça en auditant la surface publique avec la requête de contrôle suivante :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Liste les fonctions executables par anon (dangereux par défaut)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EXECUTE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Elle m'a sorti quinze fonctions que je n'avais jamais voulu exposer. Correction en bloc et &lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt; pour que les futures fonctions héritent des bons droits :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Fermer toutes les fonctions existantes à anon&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Que les futures fonctions héritent de la règle&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Les flux publics légitimes — formulaire d'inscription, signature d'émargement par QR code — transitent tous par des &lt;em&gt;API routes Next.js&lt;/em&gt; qui utilisent la &lt;code&gt;service_role&lt;/code&gt;. Révoquer &lt;code&gt;anon&lt;/code&gt; n'a rien cassé. Ce qui aurait dû être le comportement par défaut, et qui ne l'est pas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piège 3 — Les policies d'écriture sans role check
&lt;/h2&gt;

&lt;p&gt;Troisième piège. Tu actives &lt;em&gt;RLS&lt;/em&gt; sur une table, tu écris une &lt;em&gt;policy&lt;/em&gt; &lt;em&gt;SELECT&lt;/em&gt; qui dit que tout utilisateur authentifié peut lire. Tu oublies d'écrire la &lt;em&gt;policy&lt;/em&gt; &lt;em&gt;INSERT / UPDATE / DELETE&lt;/em&gt;, et &lt;em&gt;Supabase&lt;/em&gt; fait le pire choix possible : il autorise, parce qu'en &lt;em&gt;Postgres&lt;/em&gt;, sans &lt;em&gt;policy&lt;/em&gt; d'écriture explicite, la table est ouverte à tout rôle qui a le droit &lt;em&gt;Postgres&lt;/em&gt; de base.&lt;/p&gt;

&lt;p&gt;Autrement dit, n'importe quel utilisateur authentifié peut écrire dans n'importe quelle table dont tu n'as posé que la &lt;em&gt;policy&lt;/em&gt; de lecture. Un élève qui a un compte peut insérer une ligne dans &lt;code&gt;contrats_formateurs&lt;/code&gt;. Il ne le fera pas, mais il &lt;em&gt;pourrait&lt;/em&gt;, et le jour où un compte est compromis, le périmètre d'attaque est toute ta base.&lt;/p&gt;

&lt;p&gt;Le pattern que j'applique désormais sur toute nouvelle table : une &lt;em&gt;policy&lt;/em&gt; &lt;em&gt;SELECT&lt;/em&gt; pour &lt;code&gt;staff+&lt;/code&gt;, une &lt;em&gt;policy&lt;/em&gt; &lt;em&gt;INSERT / UPDATE / DELETE&lt;/em&gt; pour &lt;code&gt;admin+&lt;/code&gt; seulement, avec &lt;em&gt;role check&lt;/em&gt; explicite sur &lt;code&gt;user_roles&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Lecture staff et au-dessus&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"select_staff"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contrats_formateurs&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'staff'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Écriture admin uniquement&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"write_admin"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contrats_formateurs&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&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;Le &lt;code&gt;WITH CHECK&lt;/code&gt; est la moitié qu'on oublie toujours. Sans lui, un utilisateur autorisé à écrire peut écrire une ligne qu'il ne serait &lt;strong&gt;pas autorisé à lire ensuite&lt;/strong&gt;. C'est un classique des audits &lt;em&gt;RLS&lt;/em&gt; : la politique de lecture et la politique d'écriture doivent converger, ou le système devient incohérent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piège 4 — Le bucket Storage public oublié
&lt;/h2&gt;

&lt;p&gt;Dernier piège, celui qui fait les gros titres quand il fuite. Tu crées un bucket &lt;em&gt;Supabase Storage&lt;/em&gt; pour stocker des signatures manuscrites, des pièces justificatives, des photos d'identité — bref, des données soumises au &lt;em&gt;RGPD&lt;/em&gt;. Par défaut, le bucket est &lt;em&gt;public&lt;/em&gt;. Tu as probablement posé &lt;em&gt;RLS&lt;/em&gt; sur tes tables, tu es fier, tu oublies que les fichiers vivent à côté, avec leurs propres règles.&lt;/p&gt;

&lt;p&gt;Concrètement : n'importe qui connaissant l'URL d'un fichier peut le télécharger, et l'URL est parfois traçable, devinable, ou exposée dans un &lt;em&gt;path&lt;/em&gt; enregistré en clair dans une colonne de ta base. J'ai mis trois semaines à m'en apercevoir. La correction tient en deux étapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Étape 1&lt;/strong&gt; : passer le bucket en privé via le dashboard &lt;em&gt;Supabase&lt;/em&gt;, ou par migration :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signatures'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Étape 2&lt;/strong&gt; : côté code, ne plus utiliser &lt;code&gt;getPublicUrl()&lt;/code&gt; mais stocker le &lt;em&gt;path&lt;/em&gt; et servir le fichier via une &lt;em&gt;API route&lt;/em&gt; authentifiée qui vérifie la permission et retourne un &lt;em&gt;signed URL&lt;/em&gt; expirant en cinq minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ URL publique, valable pour toujours, indexable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signatures&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="nf"&gt;getPublicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Signed URL expirant, après vérification de permission&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signatures&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="nf"&gt;createSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 5 minutes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Le cinquième piège, en bonus, celui qu'on ne voit pas venir
&lt;/h2&gt;

&lt;p&gt;Il y en a un autre, plus rare mais spectaculaire quand il se déclenche : la &lt;strong&gt;récursion infinie sur les &lt;em&gt;policies&lt;/em&gt; &lt;code&gt;user_roles&lt;/code&gt;&lt;/strong&gt;. Si ta &lt;em&gt;policy&lt;/em&gt; sur &lt;code&gt;user_roles&lt;/code&gt; utilise elle-même un &lt;code&gt;EXISTS (SELECT 1 FROM user_roles...)&lt;/code&gt; pour vérifier le rôle, tu as créé une boucle : lire &lt;code&gt;user_roles&lt;/code&gt; appelle la &lt;em&gt;policy&lt;/em&gt; qui lit &lt;code&gt;user_roles&lt;/code&gt; qui appelle la &lt;em&gt;policy&lt;/em&gt;. &lt;em&gt;Postgres&lt;/em&gt; te renvoie une erreur &lt;code&gt;infinite recursion detected in policy&lt;/code&gt;, et toutes les requêtes qui passent par cette table échouent.&lt;/p&gt;

&lt;p&gt;La parade : la &lt;em&gt;policy&lt;/em&gt; &lt;code&gt;user_roles&lt;/code&gt; ne peut pas référencer &lt;code&gt;user_roles&lt;/code&gt;. Elle doit être formulée sur &lt;code&gt;auth.email()&lt;/code&gt; directement, ou contourner via une &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, ou — ce que j'ai fait pendant plusieurs semaines avant de trouver mieux — laisser la table accessible en &lt;em&gt;read-only&lt;/em&gt; à tout authentifié et protéger l'écriture ailleurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier dans ton projet
&lt;/h2&gt;

&lt;p&gt;Quatre réflexes directement applicables :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit de la surface &lt;code&gt;anon&lt;/code&gt;&lt;/strong&gt; — la requête &lt;em&gt;SQL&lt;/em&gt; ci-dessus sort en trente secondes la liste des fonctions exposées. Si tu n'as jamais fait cet audit, fais-le aujourd'hui&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createSupabaseAdmin()&lt;/code&gt; par défaut côté Server Component&lt;/strong&gt; — l'auth est déjà vérifiée en amont par ton &lt;em&gt;middleware&lt;/em&gt;. Le client &lt;em&gt;SSR&lt;/em&gt; avec &lt;code&gt;anon key&lt;/code&gt; est une usine à requêtes vides silencieuses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un couple &lt;code&gt;USING&lt;/code&gt; + &lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/strong&gt; sur chaque &lt;em&gt;policy&lt;/em&gt; d'écriture. Pas de politique d'écriture sans &lt;em&gt;check&lt;/em&gt;. Pas de politique de lecture sans politique d'écriture&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un script de diff qui liste les tables avec &lt;em&gt;RLS&lt;/em&gt; activée mais sans *policies&lt;/strong&gt;* — c'est un piège classique à la création d'une nouvelle table, et le meilleur moment de le corriger est tout de suite&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et une discipline plus large : &lt;strong&gt;un système de permissions qui ne crie pas quand il échoue est un système dangereux&lt;/strong&gt;. &lt;em&gt;RLS&lt;/em&gt; est puissante parce qu'elle est invisible, et c'est aussi pour ça qu'elle te coûtera cher. Instrumente-la : audite la surface &lt;code&gt;anon&lt;/code&gt; mensuellement, logue les requêtes qui reviennent vides sur des pages censées être peuplées, alerte quand un bucket change de visibilité.&lt;/p&gt;

&lt;p&gt;Et vous, votre dernière requête qui renvoyait zéro ligne en prod, c'était vraiment zéro ligne, ou &lt;em&gt;RLS&lt;/em&gt; qui la filtrait en silence ? Je lis les commentaires.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/rls-supabase" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/rls-supabase/&lt;/code&gt;&lt;/a&gt; — l'audit de surface anon, le couple &lt;code&gt;SELECT + WRITE&lt;/code&gt; avec &lt;code&gt;WITH CHECK&lt;/code&gt;, le pattern &lt;code&gt;user_roles&lt;/code&gt; sans récursion, la migration de privatisation Storage, le guide de sélection de client, et le détecteur RLS-sans-policies. Licence MIT.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Supabase RLS in production: four traps that silence your queries</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 27 Apr 2026 07:45:26 +0000</pubDate>
      <link>https://forem.com/michelfaure/supabase-rls-in-production-four-traps-that-silence-your-queries-525p</link>
      <guid>https://forem.com/michelfaure/supabase-rls-in-production-four-traps-that-silence-your-queries-525p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn39xzdovr4ygid6grpl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frn39xzdovr4ygid6grpl.png" alt="Comic strip — Adèle blocked on the Messages tab by RLS, Michel silently diagnoses and fixes the policy: " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;em&gt;« Your enrollments — how many? Because I see zero »&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;One Tuesday morning, I had just enabled &lt;em&gt;RLS&lt;/em&gt; on eighteen tables of &lt;em&gt;Rembrandt&lt;/em&gt;, L'Atelier Palissy's ERP. &lt;em&gt;Policies&lt;/em&gt; written, tested in direct &lt;em&gt;SQL&lt;/em&gt;, everything passing. Prod deploy, coffee. Françoise calls from the next office — she doesn't come over, she shouts from her chair. &lt;em&gt;« Bon. Tes inscriptions sur le site de Maisons-Laffitte, il y en a combien, dis-moi ? Moi j'en vois zéro. »&lt;/em&gt; — &lt;em&gt;Right. Your enrollments on the Maisons-Laffitte site, how many are there? Because I see zero.&lt;/em&gt; I open the same page on my machine. Zero too. No exception, no 500, no &lt;em&gt;Sentry&lt;/em&gt; error log. Just zero rows, which is precisely what makes the bug dangerous: Françoise sees nothing to fix, she sees an empty school.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Row Level Security&lt;/em&gt; is one of the rare &lt;em&gt;Postgres/Supabase&lt;/em&gt; features that can break your application &lt;strong&gt;silently&lt;/strong&gt;. A misconfiguration doesn't return an error. It returns an empty set, or worse, a partial set that passes through code without alerting it. I spent four weeks running into four distinct traps, naming them, documenting them. This article gathers them.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you have 30 seconds.&lt;/strong&gt; Well-configured &lt;em&gt;RLS&lt;/em&gt; is the best data guardrail you can put on a Supabase database. Misconfigured &lt;em&gt;RLS&lt;/em&gt; is the worst bug because it never screams. The four traps: wrong &lt;em&gt;Supabase&lt;/em&gt; client in &lt;em&gt;Server&lt;/em&gt; Components, &lt;em&gt;RPC SECURITY DEFINER&lt;/em&gt; open to &lt;em&gt;anon&lt;/em&gt;, write &lt;em&gt;policies&lt;/em&gt; without &lt;em&gt;role check&lt;/em&gt;, forgotten public &lt;em&gt;Storage&lt;/em&gt; bucket. Each has a silent symptom — empty query, public endpoint, open write, exposed file — and a five-minute fix once the cause is found. The article gives the four symptoms and the four fixes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trap 1 — The wrong client in a Server Component
&lt;/h2&gt;

&lt;p&gt;This is the trap that put Françoise in front of an empty school. &lt;em&gt;Supabase&lt;/em&gt; exposes three distinct clients, and their difference isn't obvious at first glance.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseBrowser()&lt;/code&gt; with the &lt;em&gt;anon key&lt;/em&gt;, client-side in the browser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseServer()&lt;/code&gt; with the &lt;em&gt;anon key&lt;/em&gt; plus the auth cookie, server-side in a &lt;em&gt;Server Component&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createSupabaseAdmin()&lt;/code&gt; with the &lt;em&gt;service_role key&lt;/em&gt;, server-side, bypasses &lt;em&gt;RLS&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap: if you use &lt;code&gt;createSupabaseServer()&lt;/code&gt; in a &lt;em&gt;Server Component&lt;/em&gt; but the auth cookie doesn't transit correctly — misconfigured middleware, expired &lt;em&gt;refresh token&lt;/em&gt;, proxy route reshaping the request — the &lt;em&gt;JWT&lt;/em&gt; falls back to &lt;em&gt;anon&lt;/em&gt;. No &lt;em&gt;policy&lt;/em&gt; matches for an &lt;em&gt;anon&lt;/em&gt; user. The query returns zero rows. No error, because technically the query is valid; &lt;em&gt;Postgres&lt;/em&gt; simply found nothing that matched.&lt;/p&gt;

&lt;p&gt;The rule I eventually wrote in my &lt;code&gt;CLAUDE.md&lt;/code&gt; and in an auto-invoked agent &lt;em&gt;skill&lt;/em&gt;: &lt;strong&gt;in a &lt;em&gt;Server Component&lt;/em&gt;, use &lt;code&gt;createSupabaseAdmin()&lt;/code&gt;, never &lt;code&gt;createSupabaseServer()&lt;/code&gt;&lt;/strong&gt;. Authentication is already verified upstream by the route-guarding middleware, the &lt;code&gt;service_role&lt;/code&gt; never reaches the browser, and queries return what they're supposed to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Silently empty if auth doesn't pass&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSupabaseServer&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;@/lib/supabase-server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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="c1"&gt;// data = [] with no error&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Auth already verified by middleware, RLS bypassed&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSupabaseAdmin&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;@/lib/supabase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 2 — RPC functions open to &lt;code&gt;anon&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Second trap, more insidious because it hurts in the opposite direction: you don't have too few readers, you have &lt;em&gt;too many&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Supabase&lt;/em&gt; generates REST endpoints for every &lt;em&gt;Postgres&lt;/em&gt; function declared &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, and by default &lt;code&gt;PUBLIC&lt;/code&gt; has execution rights. And &lt;code&gt;PUBLIC&lt;/code&gt; in &lt;em&gt;Postgres&lt;/em&gt; includes the &lt;code&gt;anon&lt;/code&gt; role, which is the role used when someone hits your endpoint with &lt;code&gt;curl&lt;/code&gt; without a token. In other words, your calculation functions — &lt;em&gt;pay_echeance_tx&lt;/em&gt;, &lt;em&gt;publier_planning_tx&lt;/em&gt;, &lt;em&gt;convertir_sd_tx&lt;/em&gt; — are exposed by default to anyone on the internet.&lt;/p&gt;

&lt;p&gt;I discovered this by auditing the public surface with the following control query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- List functions executable by anon (dangerous by default)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EXECUTE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned fifteen functions I had never wanted to expose. Bulk fix, plus &lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt; so future functions inherit the right permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Close all existing functions to anon&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- So future functions inherit the rule&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Legitimate public flows — enrollment form, QR-code attendance signing — all go through &lt;em&gt;Next.js API routes&lt;/em&gt; that use the &lt;code&gt;service_role&lt;/code&gt;. Revoking &lt;code&gt;anon&lt;/code&gt; broke nothing. What should have been the default behavior, and isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3 — Write policies without a role check
&lt;/h2&gt;

&lt;p&gt;Third trap. You enable &lt;em&gt;RLS&lt;/em&gt; on a table, you write a &lt;em&gt;SELECT&lt;/em&gt; &lt;em&gt;policy&lt;/em&gt; saying any authenticated user can read. You forget to write the &lt;em&gt;INSERT / UPDATE / DELETE&lt;/em&gt; &lt;em&gt;policy&lt;/em&gt;, and &lt;em&gt;Supabase&lt;/em&gt; makes the worst possible choice: it allows it, because in &lt;em&gt;Postgres&lt;/em&gt;, without an explicit write &lt;em&gt;policy&lt;/em&gt;, the table is open to any role with basic &lt;em&gt;Postgres&lt;/em&gt; rights.&lt;/p&gt;

&lt;p&gt;In other words, any authenticated user can write to any table where you only set the read policy. A student with an account can insert a row into &lt;code&gt;contrats_formateurs&lt;/code&gt;. They won't, but they &lt;em&gt;could&lt;/em&gt;, and the day an account gets compromised, the attack surface is your whole database.&lt;/p&gt;

&lt;p&gt;The pattern I now apply to every new table: a &lt;em&gt;SELECT&lt;/em&gt; &lt;em&gt;policy&lt;/em&gt; for &lt;code&gt;staff+&lt;/code&gt;, an &lt;em&gt;INSERT / UPDATE / DELETE&lt;/em&gt; &lt;em&gt;policy&lt;/em&gt; for &lt;code&gt;admin+&lt;/code&gt; only, with explicit &lt;em&gt;role check&lt;/em&gt; against &lt;code&gt;user_roles&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Read for staff and above&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"select_staff"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contrats_formateurs&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'staff'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Write for admin only&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"write_admin"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contrats_formateurs&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_roles&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'super_admin'&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;The &lt;code&gt;WITH CHECK&lt;/code&gt; is the half that's always forgotten. Without it, a user allowed to write can write a row they would &lt;strong&gt;not be allowed to read back&lt;/strong&gt;. It's a classic in &lt;em&gt;RLS&lt;/em&gt; audits: read policy and write policy must converge, or the system becomes inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4 — The forgotten public Storage bucket
&lt;/h2&gt;

&lt;p&gt;Last trap, the one that makes headlines when it leaks. You create a &lt;em&gt;Supabase Storage&lt;/em&gt; bucket to store handwritten signatures, supporting documents, ID photos — GDPR-sensitive data. By default, the bucket is &lt;em&gt;public&lt;/em&gt;. You've probably set &lt;em&gt;RLS&lt;/em&gt; on your tables, you're proud, you forget the files live alongside, with their own rules.&lt;/p&gt;

&lt;p&gt;Concretely: anyone who knows a file's URL can download it, and the URL is sometimes traceable, guessable, or exposed as a plain-text path in a database column. It took me three weeks to notice. The fix is two steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt;: make the bucket private via the &lt;em&gt;Supabase&lt;/em&gt; dashboard, or by migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signatures'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt;: in code, stop using &lt;code&gt;getPublicUrl()&lt;/code&gt;. Store the path instead and serve the file through an authenticated &lt;em&gt;API route&lt;/em&gt; that checks permission and returns a &lt;em&gt;signed URL&lt;/em&gt; expiring in five minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Public URL, valid forever, indexable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signatures&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="nf"&gt;getPublicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Expiring signed URL, after permission check&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signatures&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="nf"&gt;createSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 5 minutes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fifth trap, as a bonus, the one you don't see coming
&lt;/h2&gt;

&lt;p&gt;There's another, rarer but spectacular when it fires: the &lt;strong&gt;infinite recursion on &lt;code&gt;user_roles&lt;/code&gt; policies&lt;/strong&gt;. If your policy on &lt;code&gt;user_roles&lt;/code&gt; itself uses an &lt;code&gt;EXISTS (SELECT 1 FROM user_roles…)&lt;/code&gt; to verify the role, you've created a loop: reading &lt;code&gt;user_roles&lt;/code&gt; calls the policy that reads &lt;code&gt;user_roles&lt;/code&gt; that calls the policy. &lt;em&gt;Postgres&lt;/em&gt; returns an &lt;code&gt;infinite recursion detected in policy&lt;/code&gt; error, and every query that goes through that table fails.&lt;/p&gt;

&lt;p&gt;The fix: the &lt;code&gt;user_roles&lt;/code&gt; policy can't reference &lt;code&gt;user_roles&lt;/code&gt;. It has to be formulated on &lt;code&gt;auth.email()&lt;/code&gt; directly, or routed through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, or — what I did for several weeks before finding better — leave the table readable to any authenticated user and protect writes elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy into your project
&lt;/h2&gt;

&lt;p&gt;Four directly applicable reflexes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit of the &lt;code&gt;anon&lt;/code&gt; surface&lt;/strong&gt; — the SQL query above returns, in thirty seconds, the list of exposed functions. If you've never run this audit, do it today&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createSupabaseAdmin()&lt;/code&gt; by default in Server Components&lt;/strong&gt; — auth is already verified upstream by your middleware. The SSR client with &lt;code&gt;anon key&lt;/code&gt; is a silent-empty-query factory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;USING&lt;/code&gt; + &lt;code&gt;WITH CHECK&lt;/code&gt; pair&lt;/strong&gt; on every write policy. No write policy without a check. No read policy without a write policy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A diff script that lists tables with RLS enabled but no policies&lt;/strong&gt; — it's a classic trap when creating a new table, and the best time to fix it is immediately&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And a broader discipline: &lt;strong&gt;a permission system that doesn't scream when it fails is a dangerous system&lt;/strong&gt;. &lt;em&gt;RLS&lt;/em&gt; is powerful because it's invisible, and that's also why it will cost you. Instrument it: audit the &lt;code&gt;anon&lt;/code&gt; surface monthly, log queries that come back empty on pages that should be populated, alert when a bucket changes visibility.&lt;/p&gt;

&lt;p&gt;And you — your last query that returned zero rows in production, was it really zero rows, or &lt;em&gt;RLS&lt;/em&gt; filtering silently? I read the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/rls-supabase" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/rls-supabase/&lt;/code&gt;&lt;/a&gt; — the anon-surface audit, the &lt;code&gt;SELECT + WRITE&lt;/code&gt; policy pair with &lt;code&gt;WITH CHECK&lt;/code&gt;, the recursion-safe &lt;code&gt;user_roles&lt;/code&gt; pattern, the storage privatization migration, the client selection guide, and the RLS-without-policies detector. MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Combien vaut 91 000 lignes produites avec Claude Code ?</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sun, 26 Apr 2026 08:29:52 +0000</pubDate>
      <link>https://forem.com/michelfaure/combien-vaut-91-000-lignes-produites-avec-claude-code--3f0l</link>
      <guid>https://forem.com/michelfaure/combien-vaut-91-000-lignes-produites-avec-claude-code--3f0l</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sg32qkewre6ude5at9r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sg32qkewre6ude5at9r.png" alt="Strip BD — Le dashboard de Michel affiche 230–430 k€, mais il demande « And in 2027? », découvre la réalité du coût (500 k€ écrits), et conclut : « the metric lies harder every day »"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;J'ai codé l'ERP de notre école d'art en 91 000 lignes, en 4 semaines, avec Claude Code. Mon dashboard l'a valorisé entre 230 000 et 430 000 €. Un week-end plus tôt, je venais de comprendre qu'un pack de consulting à 5 chiffres signé quelques mois plus tôt chez un éditeur ERP commercial ne valait plus rien pour nous. Voici comment j'ai découvert que la méthode « lignes × TJM avec décote IA » ne résistera à aucun audit sérieux en 2027, et vers quoi j'ai pivoté.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qui écrit ceci
&lt;/h2&gt;

&lt;p&gt;Je m'appelle Michel Faure. Je dirige &lt;strong&gt;L'Atelier Palissy&lt;/strong&gt;, un réseau d'ateliers de céramique à l'ancienne, six sites à Paris et en région parisienne. Je ne suis pas développeur de formation. Je pilote une structure où il faut faire tourner inscriptions, planning, facturation, communication, conformité Qualiopi et finance pour plusieurs centaines d'élèves. Depuis quatre semaines, je code l'ERP métier qui remplace notre empilement d'outils. Seul, avec Claude Code.&lt;/p&gt;

&lt;p&gt;C'est le contexte de ce que je raconte ici.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le chiffre qui ne tient pas
&lt;/h2&gt;

&lt;p&gt;Au 14 avril 2026, mon dashboard affichait fièrement : &lt;strong&gt;90 947 lignes, 345 commits, valorisation 230 à 430 k€&lt;/strong&gt;. Je le regardais chaque matin. Il gamifiait le travail, il donnait une direction, il justifiait le temps investi.&lt;/p&gt;

&lt;p&gt;Le calcul était simple, et c'est ce qui le rendait séduisant :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TJM senior Next.js/Supabase      : 500-700 €/jour
Productivité standard            : ~125 lignes/jour
Facteur conception/debug/intégr. : × 2,5
Décote assistance IA             : ÷ 3 à 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chaque ligne de code valait donc, selon ce modèle, entre 8 et 14 €. 91 000 lignes × fourchette × pondération métier = environ 300 k€ au centre. Défendable en apparence.&lt;/p&gt;

&lt;p&gt;Sauf qu'à force de regarder ce chiffre monter, un doute s'est installé. Et ce doute avait une histoire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le week-end qui a tout changé
&lt;/h2&gt;

&lt;p&gt;Quelques mois avant de démarrer Rembrandt — c'est le nom que j'ai donné à notre ERP — nous avions fait ce que font la plupart des PME françaises : nous avions signé avec un éditeur ERP commercial européen très connu. Licences annuelles, un pack de consulting à 5 chiffres, engagement contractuel reconduit tacitement, facturation des développements custom &lt;strong&gt;au nombre de lignes produites&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Le déploiement devait résoudre nos problèmes. Je n'ai pas attendu la fin du déploiement pour me poser une question simple, un samedi matin : &lt;strong&gt;et si je faisais un prototype de notre workflow métier moi-même, en un week-end, avec Claude Code ?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lundi soir, le prototype couvrait 70 % de nos besoins critiques. Pas 70 % de la promesse de l'éditeur : 70 % de &lt;em&gt;notre réalité&lt;/em&gt;. Cours, places, inscriptions, émargement, flux doré lead → inscription. Fonctionnel, déployé, utilisable.&lt;/p&gt;

&lt;p&gt;Ce week-end a fait basculer deux choses :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Le pack de consulting payé ne servait plus à rien.&lt;/strong&gt; Sur les 100 heures de prestations prévues, zéro avaient été consommées. L'éditeur a refusé le remboursement. Position ferme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La facturation au nombre de lignes devenait absurde.&lt;/strong&gt; Payer au LOC pour du code custom quand j'en produisais 3 000 lignes par jour avec Claude Code, c'était monétiser une unité dont le coût réel avait été divisé par dix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et pourtant, tenir ce choix a été &lt;strong&gt;beaucoup plus difficile que la décision technique&lt;/strong&gt;. Parce qu'on avait déjà payé. Parce que l'éditeur ne remboursait pas. Parce que toute la logique de &lt;strong&gt;rentabilisation de l'investissement initial&lt;/strong&gt; poussait à continuer. Le biais du coût irrécupérable, vécu en direct.&lt;/p&gt;

&lt;p&gt;C'est en sortant de ce dilemme que j'ai commencé à regarder mon propre dashboard de valorisation avec suspicion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Les trois défauts structurels du modèle LOC
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Le modèle va dans le sens inverse du coût réel
&lt;/h3&gt;

&lt;p&gt;Claude Code continue de progresser. Cursor aussi. Les assistants spécialisés aussi. Le coût d'écriture d'une ligne a été divisé par 10 en 18 mois, et la trajectoire n'est pas terminée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plus je produis vite, plus le dashboard monte — alors que le coût marginal de production chute.&lt;/strong&gt; À l'horizon 2028, je pourrais afficher 200 000 lignes à 500 k€ pour un coût réel de quelques dizaines de k€. Aucun expert-comptable ne signera ça. Aucun repreneur ne paiera ça. La métrique ment de plus en plus fort avec le temps.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Le modèle écrase commodité et singulier
&lt;/h3&gt;

&lt;p&gt;10 000 lignes de CRUD générique sur des contacts et des formulaires sont remplaçables par un SaaS à 100 €/mois. 10 000 lignes de logique rattrapage × 4 périodes × 6 sites × règles Qualiopi sont non-substituables.&lt;/p&gt;

&lt;p&gt;Même volume, valeurs réelles × 100 différentes. Un compteur LOC ne voit pas cette différence. Il compte des octets, pas de la valeur.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Le modèle rend invisibles les actifs non-code
&lt;/h3&gt;

&lt;p&gt;Mon ERP contient environ 3 000 contacts historicisés, 5 000 leads qualifiés, 800 inscriptions, 3 ans d'historique financier, et 16 décisions d'architecture (ADR) qui capturent la logique métier en connaissance de cause. &lt;strong&gt;Aucune ligne de code, une part significative de la valeur patrimoniale.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Le jour où quelqu'un rachèterait l'outil, c'est autant sur les données et sur le capital décisionnel que sur le code qu'il paierait. Mon modèle LOC les rendait invisibles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le pivot : quatre dimensions
&lt;/h2&gt;

&lt;p&gt;J'ai formalisé la refonte dans un ADR et j'ai retenu quatre axes :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;th&gt;Calcul&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coût de remplacement SaaS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Contrefactuel : ce que je paierais si l'ERP n'existait pas&lt;/td&gt;
&lt;td&gt;Σ abonnements équivalents × 5 ans actualisé 8 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Valeur d'usage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Productivité humaine économisée&lt;/td&gt;
&lt;td&gt;Heures/trimestre × coût horaire chargé × 5 ans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Valeur patrimoniale données&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Actif immatériel non régénérable&lt;/td&gt;
&lt;td&gt;Volumes × prix unitaire marché + capital ADR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Valeur stratégique&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optionalité et souveraineté&lt;/td&gt;
&lt;td&gt;Vélocité, absence lock-in, alignement IA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La valorisation consolidée est la &lt;strong&gt;somme&lt;/strong&gt; des quatre, pas un max, pas une moyenne. Chaque dimension produit un intervalle min/centre/max, et chaque euro affiché peut être justifié par une méthode transparente et une source traçable.&lt;/p&gt;

&lt;p&gt;Le compteur de lignes reste dans le dashboard, mais dégradé au rang d'&lt;strong&gt;indicateur de volume de production&lt;/strong&gt; — l'équivalent du nombre de pages d'un livre pour un auteur. Il n'entre plus dans la valorisation monétaire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que ça change concrètement
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;La valeur affichée ne diverge plus du coût réel de production&lt;/li&gt;
&lt;li&gt;Une baisse du prix de la ligne à 5 €/ligne en 2028 ne casse pas le modèle, parce que le modèle n'en dépend plus&lt;/li&gt;
&lt;li&gt;La dimension 1 produit naturellement une &lt;strong&gt;liste de concurrents à surveiller&lt;/strong&gt; : si un SaaS vertical couvre 80 % du scope à 200 €/mois, le signal stratégique est immédiat&lt;/li&gt;
&lt;li&gt;Le dialogue avec l'expert-comptable devient direct : les 4 dimensions mappent sur les catégories comptables classiques (investissement équivalent, productivité, actif immatériel, goodwill)&lt;/li&gt;
&lt;li&gt;Les achievements « 100k, 150k lignes » disparaissent du dashboard : ils récompensaient le volume, pas la valeur&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Le moment où j'ai vraiment basculé
&lt;/h2&gt;

&lt;p&gt;Le même jour, plus tard. J'ai posé mon garde-fou de vingt lignes pour que le compteur ne me mente plus sur les dumps SQL, et je pense avoir gagné la matinée. Vers dix-sept heures, je retourne regarder le delta nettoyé du bruit : 4 281 lignes produites en vrai sur la journée, sans le dump. Je m'apprête à me féliciter, et je m'arrête.&lt;/p&gt;

&lt;p&gt;Ces 4 281 lignes, je sais ce qu'elles contiennent. Majoritairement, c'est de l'instrumentation Sentry, deux scripts CI qui durcissent un chantier déjà écrit, un refactor d'émargement qui n'ajoute aucune fonctionnalité. De la dette qui se rembourse, pas de la valeur qui se crée. Sur le papier, toutes égales devant le compteur. Dans les faits, la dette remboursée n'est pas un actif, elle est un non-passif.&lt;/p&gt;

&lt;p&gt;Je comprends là, précisément, que nettoyer les entrées n'aurait jamais suffi. La métrique que j'avais voulue n'était pas sale, elle était &lt;strong&gt;structurellement incapable&lt;/strong&gt; de voir la différence entre &lt;em&gt;produire de la valeur&lt;/em&gt;, &lt;em&gt;rembourser de la dette&lt;/em&gt;, et &lt;em&gt;importer du texte&lt;/em&gt;. Trois natures économiques distinctes, un seul compteur, un seul euro par ligne. Aucune décote IA, aucun facteur pondérateur, aucune correction statistique ne rattraperait cet écrasement.&lt;/p&gt;

&lt;p&gt;La décision de pivoter n'a rien pris de plus que d'écrire cette phrase sur un post-it et de la coller au bord de l'écran. Le lendemain matin, j'ai ouvert l'ADR-0009.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que je n'ai pas encore résolu
&lt;/h2&gt;

&lt;p&gt;La refonte complète du module de valorisation représente une dizaine d'heures réparties en trois vagues. La dimension « valeur d'usage » impose d'instrumenter la mesure des heures gagnées — chronométrer ses collègues est socialement coûteux, l'auto-déclaration trimestrielle est la seule piste soutenable. La dimension « valeur stratégique » reste opinion-driven et exige un cadrage explicite des hypothèses pour rester défendable.&lt;/p&gt;

&lt;p&gt;Enfin, la bascule produit une discontinuité dans le dashboard. Passer de 300 k€ à 450 k€ du jour au lendemain sans avoir écrit une ligne de code supplémentaire, ça demande une annotation visuelle et une note de méthodologie, sinon ça se lit comme un gain suspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trois choses à retenir
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;La ligne de code n'est plus une unité de valeur&lt;/strong&gt; à l'ère de l'agent coding. Elle redevient ce qu'elle aurait toujours dû être : un indicateur de volume de production, rien de plus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valorisez ce que votre code remplace, fait gagner, capture, et rend possible&lt;/strong&gt; — pas ce qu'il a coûté à écrire. Le coût de production continue de chuter, la valeur créée ne suit pas la même pente.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La vraie question n'est pas ce que vous avez déjà dépensé, c'est ce que vous économiserez si vous arrêtez maintenant.&lt;/strong&gt; C'est la leçon la plus dure à tenir. Elle ne se démontre pas avec un tableau Excel. Elle se tient contre soi-même, contre le poids des investissements passés, contre la pression sociale de « finir ce qu'on a commencé ».&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Et vous ?
&lt;/h2&gt;

&lt;p&gt;Si vous codez avec un assistant IA et que vous vous posez la question de la valeur de votre travail, je suis curieux : &lt;strong&gt;comment la mesurez-vous, aujourd'hui ?&lt;/strong&gt; Et si vous avez déjà fait le pivot « rentabiliser un ERP commercial vs construire un outil sur-mesure avec l'IA », racontez. Les commentaires sont ouverts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cet article fait partie d'une série sur le développement d'un ERP de 91 000 lignes en 4 semaines avec Claude Code pour L'Atelier Palissy, école d'art céramique. Le prochain article détaille la méthode à 4 dimensions dans le concret, avec les formules et les seeds initiaux du module.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/valorisation/&lt;/code&gt;&lt;/a&gt; — le pattern &lt;code&gt;consolidate(dims)&lt;/code&gt; à quatre dimensions et le garde-fou Slack sur le compteur de LOC, licence MIT.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How much are 91,000 lines produced with Claude Code actually worth?</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sun, 26 Apr 2026 08:27:41 +0000</pubDate>
      <link>https://forem.com/michelfaure/how-much-are-91000-lines-produced-with-claude-code-actually-worth-3kfn</link>
      <guid>https://forem.com/michelfaure/how-much-are-91000-lines-produced-with-claude-code-actually-worth-3kfn</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sg32qkewre6ude5at9r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sg32qkewre6ude5at9r.png" alt="Comic strip — Michel's dashboard reads €230–430k, but he asks "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I coded my art school's ERP in 91,000 lines, in 4 weeks, with Claude Code. My dashboard valued it between €230,000 and €430,000. A weekend earlier, I had just understood that a five-figure consulting package signed a few months before with a commercial ERP vendor was worth nothing to us anymore. Here's how I discovered that the "lines × day-rate with AI discount" method will not survive any serious audit in 2027, and what I pivoted toward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who is writing this
&lt;/h2&gt;

&lt;p&gt;My name is Michel Faure. I run &lt;strong&gt;L'Atelier Palissy&lt;/strong&gt;, a network of traditional ceramics workshops, six sites in Paris and the greater Paris area. I'm not a developer by training. I run a structure that has to keep enrollments, scheduling, billing, communication, Qualiopi compliance and finance working for several hundred students. For four weeks, I've been coding the business ERP that replaces our pile of tools. Alone, with Claude Code.&lt;/p&gt;

&lt;p&gt;That's the context for everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that doesn't hold
&lt;/h2&gt;

&lt;p&gt;As of April 14th, 2026, my dashboard proudly displayed: &lt;strong&gt;90,947 lines, 345 commits, valuation €230k–€430k&lt;/strong&gt;. I looked at it every morning. It gamified the work, gave it direction, justified the time invested.&lt;/p&gt;

&lt;p&gt;The calculation was simple, which is what made it seductive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Senior Next.js/Supabase day-rate   : €500–€700/day
Standard productivity              : ~125 lines/day
Design/debug/integration factor    : × 2.5
AI assistance discount             : ÷ 3 to 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line of code was therefore worth, according to this model, between €8 and €14. 91,000 lines × range × business weighting = around €300k at the center. Apparently defensible.&lt;/p&gt;

&lt;p&gt;Except that as I watched the number climb, a doubt settled in. And that doubt had a history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weekend that changed everything
&lt;/h2&gt;

&lt;p&gt;A few months before starting Rembrandt — that's the name I gave our ERP — we had done what most French SMBs do: we had signed with a well-known European commercial ERP vendor. Annual licenses, a five-figure consulting package, contractually renewed tacitly, billing of custom developments &lt;strong&gt;per line of code produced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The rollout was supposed to solve our problems. I didn't wait for the end of the rollout to ask myself a simple question, one Saturday morning: &lt;strong&gt;what if I built a prototype of our business workflow myself, in a weekend, with Claude Code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By Monday evening, the prototype covered 70% of our critical needs. Not 70% of the vendor's promise: 70% of &lt;em&gt;our reality&lt;/em&gt;. Courses, seats, enrollments, attendance, golden flow lead → enrollment. Functional, deployed, usable.&lt;/p&gt;

&lt;p&gt;That weekend flipped two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The paid consulting package no longer served any purpose.&lt;/strong&gt; Of the 100 hours of services planned, zero had been consumed. The vendor refused the refund. Firm position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing per line became absurd.&lt;/strong&gt; Paying per LOC for custom code when I was producing 3,000 lines a day with Claude Code was monetizing a unit whose real cost had been divided by ten.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And yet, holding that choice was &lt;strong&gt;much harder than the technical decision&lt;/strong&gt;. Because we had already paid. Because the vendor wasn't refunding. Because the whole logic of &lt;strong&gt;amortizing the initial investment&lt;/strong&gt; was pushing to continue. The sunk-cost fallacy, lived in real time.&lt;/p&gt;

&lt;p&gt;It's by coming out of that dilemma that I started looking at my own valuation dashboard with suspicion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three structural flaws of the LOC model
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The model runs counter to real cost
&lt;/h3&gt;

&lt;p&gt;Claude Code keeps improving. Cursor too. Specialized assistants too. The cost of writing a line has been divided by 10 in 18 months, and the trajectory isn't over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The faster I produce, the higher the dashboard climbs — while marginal production cost falls.&lt;/strong&gt; By 2028, I could display 200,000 lines at €500k for a real cost of a few tens of thousands of euros. No accountant will sign that. No buyer will pay that. The metric lies louder and louder over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The model flattens commodity and singular
&lt;/h3&gt;

&lt;p&gt;10,000 lines of generic CRUD on contacts and forms are replaceable by a SaaS at €100/month. 10,000 lines of catch-up logic × 4 periods × 6 sites × Qualiopi rules are non-substitutable.&lt;/p&gt;

&lt;p&gt;Same volume, real values × 100 different. A LOC counter doesn't see that difference. It counts bytes, not value.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The model makes non-code assets invisible
&lt;/h3&gt;

&lt;p&gt;My ERP contains around 3,000 historicized contacts, 5,000 qualified leads, 800 enrollments, 3 years of financial history, and 16 architecture decisions (ADRs) that capture the business logic knowingly. &lt;strong&gt;Not a line of code, a significant share of the patrimonial value.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The day someone were to buy the tool, they would pay for the data and the decisional capital as much as for the code. My LOC model made them invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: four dimensions
&lt;/h2&gt;

&lt;p&gt;I formalized the overhaul in an ADR and kept four axes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SaaS replacement cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Counterfactual: what I'd pay if the ERP didn't exist&lt;/td&gt;
&lt;td&gt;Σ equivalent subscriptions × 5 years discounted at 8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Usage value&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Human productivity saved&lt;/td&gt;
&lt;td&gt;Hours/quarter × loaded hourly cost × 5 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data patrimonial value&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Non-regeneratable intangible asset&lt;/td&gt;
&lt;td&gt;Volumes × market unit price + ADR capital&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Strategic value&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optionality and sovereignty&lt;/td&gt;
&lt;td&gt;Velocity, lock-in absence, AI alignment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The consolidated valuation is the &lt;strong&gt;sum&lt;/strong&gt; of the four, not a max, not an average. Each dimension produces a min/center/max range, and every displayed euro can be justified by a transparent method and a traceable source.&lt;/p&gt;

&lt;p&gt;The line counter stays in the dashboard but is demoted to the rank of &lt;strong&gt;production-volume indicator&lt;/strong&gt; — the equivalent of a book's page count for an author. It no longer enters the monetary valuation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it changes concretely
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The displayed value no longer diverges from real production cost&lt;/li&gt;
&lt;li&gt;A drop in the line price to €5/line in 2028 doesn't break the model, because the model no longer depends on it&lt;/li&gt;
&lt;li&gt;Dimension 1 naturally produces a &lt;strong&gt;list of competitors to watch&lt;/strong&gt;: if a vertical SaaS covers 80% of the scope at €200/month, the strategic signal is immediate&lt;/li&gt;
&lt;li&gt;The dialogue with the accountant becomes direct: the 4 dimensions map onto classic accounting categories (equivalent investment, productivity, intangible asset, goodwill)&lt;/li&gt;
&lt;li&gt;The "100k, 150k lines" achievements disappear from the dashboard: they rewarded volume, not value&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The moment I really flipped
&lt;/h2&gt;

&lt;p&gt;The same day, later. I had set my twenty-line guardrail so the counter would stop lying to me about SQL dumps, and I thought I'd won the morning. Around five in the afternoon, I go back to look at the delta cleaned of noise: 4,281 lines actually produced on the day, without the dump. I'm about to congratulate myself, and I stop.&lt;/p&gt;

&lt;p&gt;Those 4,281 lines, I know what they contain. Mostly Sentry instrumentation, two CI scripts hardening a workflow already written, an attendance refactor that adds no functionality. Debt being repaid, not value being created. On paper, all equal before the counter. In fact, repaid debt isn't an asset, it's a non-liability.&lt;/p&gt;

&lt;p&gt;I understand right there, precisely, that cleaning the inputs would never have been enough. The metric I had wanted wasn't dirty, it was &lt;strong&gt;structurally incapable&lt;/strong&gt; of seeing the difference between &lt;em&gt;producing value&lt;/em&gt;, &lt;em&gt;repaying debt&lt;/em&gt;, and &lt;em&gt;importing text&lt;/em&gt;. Three distinct economic natures, one counter, one euro per line. No AI discount, no weighting factor, no statistical correction would rescue that flattening.&lt;/p&gt;

&lt;p&gt;The decision to pivot took no more than writing that sentence on a sticky note and sticking it to the edge of the screen. The next morning, I opened ADR-0009.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't yet resolved
&lt;/h2&gt;

&lt;p&gt;The full overhaul of the valuation module represents about ten hours spread over three waves. The "usage value" dimension requires instrumenting hour measurements — timing your colleagues is socially costly, quarterly self-reporting is the only sustainable path. The "strategic value" dimension remains opinion-driven and requires an explicit framing of assumptions to stay defensible.&lt;/p&gt;

&lt;p&gt;Finally, the switch produces a discontinuity in the dashboard. Going from €300k to €450k overnight without having written one additional line of code demands a visual annotation and a methodology note; otherwise it reads as a suspicious gain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things to remember
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The line of code is no longer a unit of value&lt;/strong&gt; in the era of agent coding. It becomes what it always should have been: a production-volume indicator, nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value what your code replaces, saves, captures, and makes possible&lt;/strong&gt; — not what it cost to write. Production cost keeps falling, created value doesn't follow the same slope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real question isn't what you've already spent, it's what you'll save if you stop now.&lt;/strong&gt; That's the hardest lesson to hold. It can't be proved with a spreadsheet. It holds against yourself, against the weight of past investments, against the social pressure to "finish what you started".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What about you?
&lt;/h2&gt;

&lt;p&gt;If you code with an AI assistant and wonder about the value of your work, I'm curious: &lt;strong&gt;how do you measure it, today?&lt;/strong&gt; And if you've already done the pivot "amortize a commercial ERP vs. build a custom tool with AI", share. Comments are open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of a series on building a 91,000-line ERP in four weeks with Claude Code for L'Atelier Palissy, an art school. The next article details the four-dimension method in practice, with formulas and the module's initial seeds.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/valorisation" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/valorisation/&lt;/code&gt;&lt;/a&gt; — the four-dimension &lt;code&gt;consolidate(dims)&lt;/code&gt; pattern and Slack guardrail on the LOC counter, MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
