<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michel Faure </title>
    <description>The latest articles on DEV Community by Michel Faure  (@michelfaure).</description>
    <link>https://dev.to/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>DEV Community: Michel Faure </title>
      <link>https://dev.to/michelfaure</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michelfaure"/>
    <language>en</language>
    <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://dev.to/michelfaure/4-incidents-4-regles-comment-mon-claudemd-sest-ecrit-tout-seul-dpl</link>
      <guid>https://dev.to/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://dev.to/michelfaure/4-incidents-4-rules-how-my-claudemd-wrote-itself-o3n</link>
      <guid>https://dev.to/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://dev.to/michelfaure/rls-supabase-en-prod-quatre-pieges-qui-silencent-tes-requetes-3c8m</link>
      <guid>https://dev.to/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://dev.to/michelfaure/supabase-rls-in-production-four-traps-that-silence-your-queries-525p</link>
      <guid>https://dev.to/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://dev.to/michelfaure/combien-vaut-91-000-lignes-produites-avec-claude-code--3f0l</link>
      <guid>https://dev.to/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://dev.to/michelfaure/how-much-are-91000-lines-produced-with-claude-code-actually-worth-3kfn</link>
      <guid>https://dev.to/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>
