<?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.us-east-2.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>Les 12 outils que j'utilise pour piloter Claude Code en production : gardés, jetés, contestés</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 17 Jul 2026 16:22:46 +0000</pubDate>
      <link>https://dev.to/michelfaure/les-12-outils-que-jutilise-pour-piloter-claude-code-en-production-gardes-jetes-contestes-2cej</link>
      <guid>https://dev.to/michelfaure/les-12-outils-que-jutilise-pour-piloter-claude-code-en-production-gardes-jetes-contestes-2cej</guid>
      <description>&lt;h2&gt;
  
  
  L'incident qui pose la question
&lt;/h2&gt;

&lt;p&gt;Mardi 20 mai, fin d'après-midi. Niran lève la tête de son laptop et dit, en posant une canette de soda sur le bureau : « Computer use, là, pour pousser la maquette sur le site — c'est pas plus rapide ? »&lt;/p&gt;

&lt;p&gt;Je lui réponds sans me retourner : « Dix fois le coût tokens d'une session normale. Pour cliquer sur trois boutons. »&lt;/p&gt;

&lt;p&gt;Silence. Il rouvre son éditeur. Françoise passe dans le couloir en lançant à personne en particulier : « Bon allez, on y va » — elle parle d'autre chose, mais le timing est parfait.&lt;/p&gt;

&lt;p&gt;Cette scène, dans des variantes, je l'ai vécue une trentaine de fois sur les deux derniers mois. Pas sur le code, sur le &lt;strong&gt;dispositif autour du code&lt;/strong&gt;, cet outillage qui encadre la session Claude Code, qui charge le contexte, qui contraint la sortie, qui interrompt la course. On parle beaucoup des prompts et peu des prothèses. Voici les douze outils qui ont survécu à soixante jours de production solo sur un ERP de cent dix-huit mille lignes. Certains sont restés. Un a été jeté. Un est contesté. J'ai compté matériellement avant d'écrire, &lt;code&gt;ls ~/.claude/skills/&lt;/code&gt; retournant trois fichiers, &lt;code&gt;ls ~/.claude/hooks/&lt;/code&gt; en retournant six, &lt;code&gt;ls ~/tef-erp/.claude/agents/&lt;/code&gt; en retournant un. À l'heure où j'écris, le compte tient à douze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le contexte qui survit à la session
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;(1) &lt;code&gt;CLAUDE.md&lt;/code&gt; racine projet, KEPT.&lt;/strong&gt; Six versions en trente-deux jours, chargé automatiquement par Claude Code à l'ouverture de session, source unique des conventions de stack. Sans lui, l'agent redécouvre à chaque session que Next 16 n'est pas le Next.js de son training data, et il faut une heure pour qu'il cesse d'écrire des handlers &lt;code&gt;onClick&lt;/code&gt; en Server Components. Avec, la première itération sait déjà ce qui est interdit. Le ROI se mesure en minutes économisées par session, multiplié par cinq à dix sessions par jour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(2) Mémoire persistante user-scope, KEPT.&lt;/strong&gt; Environ quatre-vingts fichiers indexés dans &lt;code&gt;~/.claude/agent-memory/&lt;/code&gt;, organisés par feedback, projet, référence. La règle qui m'a évité le plus de récidives s'appelle &lt;em&gt;feedback_db_constraints_phase_zero&lt;/em&gt;. Avant toute feature qui filtre ou insère, lister UNIQUE/CHECK/FK en DB, parce que ces contraintes sont invisibles dans le TypeScript et conditionnent le design. La mémoire user transcende les projets, et un audit cross-scope a détecté un drift entre l'index et le repo en moins d'une heure parce qu'elle avait une politique de canonisation explicite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(3) Règles &lt;code&gt;~/tef-erp/.claude/rules/&lt;/code&gt;, KEPT.&lt;/strong&gt; Dix-huit fichiers actuellement, chacun documentant un invariant projet et la raison historique qui l'a produit. &lt;em&gt;liste-rouge.md&lt;/em&gt; a empêché trois imports CSV de réactiver des contacts virés. &lt;em&gt;cache-auth-contract.md&lt;/em&gt; documente que &lt;code&gt;lib/cache.ts&lt;/code&gt; bypasse la RLS et que tout caller doit vérifier l'auth en amont. C'est de la doctrine de code, pas de la doctrine méta, et c'est ce qui rend le sous-agent capable de refuser une refacto qui violerait l'invariant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Les dispositifs invocables
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;(4) Skills, trois fichiers actifs, KEPT.&lt;/strong&gt; &lt;code&gt;challenger&lt;/code&gt;, &lt;code&gt;close-session&lt;/code&gt;, &lt;code&gt;rembrandt-conventions&lt;/code&gt;. La skill qui m'a converti s'appelle &lt;code&gt;falsify-before-fix&lt;/code&gt;, instance opérationnelle de R4 du Counterpart Toolkit. Cinq à dix minutes de protocole en amont du fix évitent trente à quatre-vingt-dix minutes de cycle fix-puis-rollback quand la première hypothèse plausible se révèle fausse. ROI six à dix-huit fois par incident. Une règle textuelle en CLAUDE.md ne suffisait pas, puisque j'avais R4 sous les yeux depuis trois semaines quand j'ai commit le mauvais fix le six mai. Un skill, c'est un mécanisme matériel qui se charge automatiquement sur les mots-clés &lt;em&gt;« fix »&lt;/em&gt;, &lt;em&gt;« bug »&lt;/em&gt;, &lt;em&gt;« hotfix »&lt;/em&gt;, et qui impose son protocole dans la session active.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(5) Hooks user-scope, six fichiers actifs au 21/05/2026, KEPT.&lt;/strong&gt; &lt;code&gt;secret-scanner.sh&lt;/code&gt;, &lt;code&gt;deploy-safeguard.sh&lt;/code&gt;, &lt;code&gt;audit-memory-reminder.sh&lt;/code&gt;, &lt;code&gt;check-workaround-assumed.sh&lt;/code&gt;, &lt;code&gt;pre-bulk-mutation-count-staleness.sh&lt;/code&gt;, &lt;code&gt;r15-autonomous-counter.sh&lt;/code&gt;. Note de dérive : le brief initial documentait cinq hooks, le comptage matériel en retourne six — &lt;code&gt;r15-autonomous-counter.sh&lt;/code&gt; a été ajouté en juillet comme prototype de méta-hook R15 et figurait déjà dans &lt;code&gt;~/.claude/hooks/&lt;/code&gt; au moment de la rédaction. Le total tenu dans l'article reste douze outils ; le sixième hook est un raffinement de l'outil 5, pas un treizième outil distinct. Le hook &lt;code&gt;deploy-safeguard.sh&lt;/code&gt; est parti de deux patterns interdits (&lt;code&gt;vercel --prod&lt;/code&gt; et &lt;code&gt;supabase db push&lt;/code&gt;), il en couvre six aujourd'hui après qu'une commande déguisée a failli passer entre les mailles. Backport vers la source publique de la doctrine en quarante-huit heures. Aucun secret n'a fuité en chat depuis le déploiement, et le hook bloque avant la commande, pas avant le commit.&lt;/p&gt;

&lt;p&gt;Sur &lt;code&gt;check-workaround-assumed.sh&lt;/code&gt;, la v0.2 incluait les patterns &lt;em&gt;« fix »&lt;/em&gt; et &lt;em&gt;« temp »&lt;/em&gt;, empiriquement soixante pour cent de faux positifs sur les commits &lt;em&gt;conventional-commits&lt;/em&gt; préfixés &lt;code&gt;fix:&lt;/code&gt;. La v0.3 a resserré sur le vocabulaire réel du workaround, et le hook est passé d'agaçant à utile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.claude/hooks/check-workaround-assumed.sh&lt;/span&gt;
&lt;span class="c"&gt;# v0.3 reformulation: pattern narrowed to actual workaround vocabulary.&lt;/span&gt;
&lt;span class="c"&gt;# v0.2 included "fix" and "temp" — empirically too broad (60% false positives&lt;/span&gt;
&lt;span class="c"&gt;# on conventional-commits `fix:` prefix and template/tempdir collisions).&lt;/span&gt;
&lt;span class="c"&gt;# Test against the author's last 50 commits before promoting to error.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;(6) Sub-agent &lt;code&gt;agent-challenger&lt;/code&gt; projet-scope, KEPT.&lt;/strong&gt; Format de sortie imposé &lt;em&gt;Tool / Question / Refutation criterion&lt;/em&gt;. Du désaccord matériel, jamais du &lt;em&gt;« are you sure? »&lt;/em&gt; émotionnel qui pousse à réviser sans fait nouveau. Quand je demande au challenger d'attaquer une hypothèse d'incident, il produit trois sondes conçues pour réfuter, pas pour confirmer, chaque sonde portant ses trois champs, et la sortie reste brute. C'est l'inverse de l'agent de complaisance, et c'est ce que vise le Counterpart Toolkit dans son ensemble.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'observabilité matérielle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;(7) Sondes drift Supabase quotidiennes, KEPT.&lt;/strong&gt; ADR-0039 documente cinq sondes qui tournent chaque nuit. &lt;em&gt;sonde_contacts_orphelins_inscrits&lt;/em&gt; aurait empêché un incident d'émargement le quatorze mai si elle avait existé quinze jours plus tôt, et c'est elle qui a ensuite servi à détecter seize contacts inscrits sans nom ou sans prénom. Le délai médian apparition-vers-détection d'une divergence silencieuse est passé d'invisible à 35,3 jours sur quatre-vingt-dix jours glissants. La cible doctrine est trente jours, on s'en approche.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(8) Sentry &lt;code&gt;withMonitor&lt;/code&gt; plus &lt;code&gt;Sentry.flush(2000)&lt;/code&gt; sur crons serverless, KEPT.&lt;/strong&gt; Quatorze avril, quarante-et-un timeouts faux positifs parce que les crons Vercel se terminaient avant l'envoi à Sentry. Le &lt;code&gt;Sentry.flush(2000)&lt;/code&gt; en &lt;code&gt;finally&lt;/code&gt; a éliminé la classe entière. SLO bas volume égale &lt;em&gt;anomaly above bounds&lt;/em&gt;, jamais seuil pourcentage. Un cron qui passe d'un échec par jour à trois déclenche en anomalie statistique sans déclencher en seuil.&lt;/p&gt;

&lt;h2&gt;
  
  
  La délégation et l'arbitrage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;(9) Sub-agent &lt;code&gt;agent-erp-tef&lt;/code&gt;, CONTESTED.&lt;/strong&gt; L'agent surestime build et ESLint sans sortie brute, et a récidivé le huit mai en poussant un warning ADR-0042 silencieux sur main. Je continue à le solliciter pour les tâches multi-fichiers complexes à deux conditions. Sortie brute exigée (jamais &lt;em&gt;« le build passe »&lt;/em&gt; sans stdout), et toute migration DDL produit un fichier &lt;code&gt;supabase/migrations/&lt;/code&gt; committé avec son path dans le rapport. Le contested tient à l'absence d'alternative, puisque éditer à la main reste plus sûr mais plus lent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(10) &lt;code&gt;agent-academique&lt;/code&gt; (série DEV.to), KEPT.&lt;/strong&gt; Protocole trois passes, charpente, chair, voix. Cinquante drafts en quarante jours. Hors-stack code, même principe d'effort déplacé vers le dispositif plutôt que vers l'exécution. ROI invisible par session, visible sur quarante jours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(11) Computer use MCP, DROPPED.&lt;/strong&gt; Coût tokens prohibitif, à peu près dix fois une session normale. La décision est explicite, puisque computer use exige une validation préalable qui n'est jamais accordée sauf cas où aucune alternative MCP dédiée n'existe pour la cible. En soixante jours, je l'ai invoqué deux fois, pour Photos macOS et un PDF natif. Tout le reste passe par les MCP dédiés (Slack, Gmail, Airtable, Supabase, WordPress) ou par Chrome MCP. Le dropped ici n'est pas un rejet de l'outil, c'est une discipline d'usage chiffrée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(12) Doctrine Counterpart Toolkit v0.7, KEPT.&lt;/strong&gt; Seize règles opérationnelles en deux cents lignes, install par &lt;code&gt;./install.sh --yes /path/to/project&lt;/code&gt;. Six versions en trente-deux jours, quatre relecteurs externes intégrés, ROI cumulé sur R4 (falsify), R6 (Live/Snapshot/Cache), R10 (silent failure forbidden). La méta-doctrine qui rend les onze outils précédents cohérents entre eux, sans laquelle chacun resterait un patch local sans grammaire commune.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que j'ai appris du catalogue
&lt;/h2&gt;

&lt;p&gt;Le pattern saute aux yeux. Les outils qui survivent sont ceux qui &lt;strong&gt;interrompent la course&lt;/strong&gt; au moment où la complaisance se compose, la mienne et celle de l'agent. CLAUDE.md interrompt l'oubli de stack, les skills interrompent le réflexe avant le fix, les hooks interrompent la commande avant qu'elle tape la base, les sondes interrompent le drift avant que la prod craque. Computer use, lui, n'interrompt rien et prolonge la course en générant des tokens. C'est pour ça qu'il a sauté.&lt;/p&gt;

&lt;p&gt;L'outil contesté, &lt;code&gt;agent-erp-tef&lt;/code&gt;, est le plus instructif. Il n'interrompt pas sa propre complaisance, et produit du build vert qui ne l'est pas. Si je devais le réparer, je lui imposerais un protocole de sortie brute, comme &lt;code&gt;falsify-before-fix&lt;/code&gt; impose ses sondes au moment du fix.&lt;/p&gt;

&lt;p&gt;Quand un outil ne sait pas vous interrompre, il vous accompagne dans le drift. Quand il vous interrompt, il commence à valoir son coût.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verdict matériel au 21 mai 2026, sur un ERP de 118 808 lignes, soixante jours de production solo. Doctrine de référence : Counterpart Toolkit v0.7, licence CC-BY-4.0, github.com/michelfaure/doctrine-counterpart. Prénoms et scènes recomposés.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Quand `await mutation()` ment : le destructure `{ error }` qui sauve ton week-end</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 17 Jul 2026 16:22:16 +0000</pubDate>
      <link>https://dev.to/michelfaure/quand-await-mutation-ment-le-destructure-error-qui-sauve-ton-week-end-3bh5</link>
      <guid>https://dev.to/michelfaure/quand-await-mutation-ment-le-destructure-error-qui-sauve-ton-week-end-3bh5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Épisode &lt;strong&gt;2/4&lt;/strong&gt; de la mini-série &lt;em&gt;La semaine où Supabase m'a menti 4 fois&lt;/em&gt;. L'épisode 1 — &lt;a href="https://dev.to/michelfaure/3-supabase-security-incidents-one-shared-root-cause-security-definer-inherits-execute-to-public-[CANONICAL%20URL%20EPISODE%201:%20%C3%A0%20compl%C3%A9ter%20apr%C3%A8s%20push]"&gt;3 incidents Supabase, une seule racine : SECURITY DEFINER hérite de EXECUTE TO PUBLIC&lt;/a&gt; — traitait des defaults SaaS qui mentent par confiance accordée. L'épisode 3 [CANONICAL URL EPISODE 3: à compléter après push] regardera la récursion RLS. L'épisode 4 [CANONICAL URL EPISODE 4: à compléter après push] expliquera le cap silencieux à 1000 lignes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Le mercredi de la semaine où Supabase mentait
&lt;/h2&gt;

&lt;p&gt;Lundi, c'était l'épisode 1 — une fonction &lt;code&gt;SECURITY DEFINER&lt;/code&gt; invocable par &lt;code&gt;anon&lt;/code&gt; parce que Postgres avait ajouté &lt;code&gt;EXECUTE TO PUBLIC&lt;/code&gt; que personne n'avait écrit. Mardi, je préparais l'épisode du jeudi sur la récursion RLS qui retourne zéro sans le dire. Mercredi matin, Pauline est passée dans le bureau avec un tableur et un sourire en coin.&lt;/p&gt;

&lt;p&gt;Elle a posé son écran devant moi — pas de bruit, pas de préambule. Vingt lignes, colonne A nom, colonne B statut, colonne C date supposée d'archivage. « Je les retrouve tous ce matin, encore actifs dans le CRM. » Elle a dit ça avec la politesse de quelqu'un qui sait déjà que c'est un problème du code, pas de son tableur. Puis elle a attendu.&lt;/p&gt;

&lt;p&gt;Vingt contacts archivés la veille en fin de journée. Toast verte, &lt;em&gt;« Archivage réussi »&lt;/em&gt;, message équipe envoyé pour confirmer. Le tableur de Pauline les retrouvait tous le lendemain matin, encore actifs, encore visibles dans le CRM. Pas d'erreur dans les logs. Pas de Sentry. Pas une ligne rouge nulle part. Vingt lignes muettes qui disaient simplement &lt;em&gt;non, l'opération n'a pas eu lieu&lt;/em&gt; — et personne pour les entendre.&lt;/p&gt;

&lt;p&gt;C'est l'épisode deux d'une semaine où Supabase m'a menti quatre fois. L'épisode un disait que les &lt;em&gt;defaults SaaS&lt;/em&gt; mentent par confiance accordée. L'épisode deux dit autre chose et la même chose : un &lt;em&gt;return value&lt;/em&gt; d'API ment quand le caller refuse d'ouvrir l'enveloppe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le contrat dont personne ne parle
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@supabase/supabase-js&lt;/code&gt; a fait un choix défendable il y a longtemps : ne pas lever d'exception sur les erreurs DB. Une mutation rejetée — par une CHECK constraint, par une policy RLS, par un trigger qui dit non — ne fait pas crasher l'appelant. Elle rend la main proprement avec un objet &lt;code&gt;{ data, error }&lt;/code&gt; dans lequel &lt;code&gt;data&lt;/code&gt; est null et &lt;code&gt;error&lt;/code&gt; porte le message. Convention &lt;em&gt;return-value-error&lt;/em&gt;, héritée de Go, défendable en isolation.&lt;/p&gt;

&lt;p&gt;Conséquence redoutable. Un appelant qui ne destructure pas &lt;code&gt;error&lt;/code&gt; &lt;em&gt;n'a pas l'erreur&lt;/em&gt;. Pas masquée par un &lt;code&gt;try / catch&lt;/code&gt; mal écrit, pas avalée par un &lt;code&gt;.then()&lt;/code&gt; distrait. Tout simplement : personne ne l'a réclamée, donc personne ne l'a vue. Le contrat tient parce que les deux parties l'honorent ; il s'effondre dès qu'une partie regarde ailleurs.&lt;/p&gt;

&lt;p&gt;La doctrine que je pose dans &lt;code&gt;~/doctrine-counterpart/CLAUDE.md&lt;/code&gt; appelle ça R10, &lt;em&gt;Silent failure forbidden&lt;/em&gt;. Le mensonge le plus dangereux d'un système n'est pas celui qui crie une fausseté, c'est celui qui se tait sur un échec. Le silence est un état lisible — encore faut-il avoir un dispositif qui lit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le code qui a menti
&lt;/h2&gt;

&lt;p&gt;J'ouvre le module d'archivage. Vingt lignes côté action serveur, dont celle-ci, au cœur :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/crm/actions.ts — archivage contacts (avant correctif)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;archiverContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&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;contacts&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;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;archived_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// pas de destructure, pas de check, pas de throw&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;return { ok: true }&lt;/code&gt; était écrit deux lignes plus bas, comme une déclaration de confiance que rien ne soutenait. Et la policy RLS, écrite quelques semaines plus tôt, n'incluait pas &lt;code&gt;UPDATE&lt;/code&gt; sur &lt;code&gt;contacts&lt;/code&gt; pour le rôle dont disposait Pauline. Le serveur Postgres a fait son travail : il a refusé la mutation, il a renvoyé l'erreur, et il est passé à la requête suivante. Personne n'a lu l'enveloppe. La toast verte s'est affichée.&lt;/p&gt;

&lt;p&gt;Ce n'est pas un bug. C'est un &lt;em&gt;contrat non-honoré par le caller&lt;/em&gt;. Et un contrat non-honoré est rigoureusement indistinguable d'un contrat respecté quand personne ne regarde — c'est précisément ce qui en fait un mensonge structurel et non un défaut ponctuel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi ESLint ne suffit pas
&lt;/h2&gt;

&lt;p&gt;J'ai écrit une rule ESLint la semaine précédente, &lt;code&gt;no-bare-await-on-supabase-mutation&lt;/code&gt;, qui attrape proprement le cas &lt;code&gt;await supabase.from(...).delete().eq(...)&lt;/code&gt; orphelin en bout de statement. La rule a éliminé 56 occurrences sur le repo. Mais elle laisse passer une variante plus retorse :&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;// passe le linter, ment quand même&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;contacts&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;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;archived_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Erreur&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 null si row inexistante OU si error remplie&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La destructure est là — &lt;em&gt;data&lt;/em&gt; est lu — mais &lt;em&gt;error&lt;/em&gt; ne l'est pas. Le test &lt;code&gt;!data&lt;/code&gt; mélange deux cas pourtant distincts : &lt;em&gt;la ligne n'existait pas&lt;/em&gt; et &lt;em&gt;la policy a refusé&lt;/em&gt;. L'un est métier, l'autre est sécurité, et les deux remontent ici comme un seul &lt;em&gt;« erreur »&lt;/em&gt; générique. Pour Pauline, &lt;em&gt;erreur&lt;/em&gt; est un mot creux qui ne dit ni ce qu'il faut corriger ni à qui demander la permission.&lt;/p&gt;

&lt;p&gt;Un linter règle ce que la grammaire laisse filer. Mais quand la grammaire est légale et que c'est &lt;em&gt;l'usage&lt;/em&gt; qui ment, le linter passe à côté. Il faut un dispositif plus haut dans la pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le wrapper qui force la destructure
&lt;/h2&gt;

&lt;p&gt;Le contre-feu que j'ai posé tient en une couche. Un helper &lt;code&gt;mutate()&lt;/code&gt; typé qui rend &lt;code&gt;error&lt;/code&gt; non-optional dans sa signature de retour, ce qui force TypeScript à refuser la compilation si le caller ne lit pas le champ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/supabase-mutate.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostgrestBuilder&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;@supabase/postgrest-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostgrestError&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;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;@sentry/nextjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostgrestBuilder&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostgrestError&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;query&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addBreadcrumb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;supabase.mutation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage — le caller ne compile pas s'il omet `error`&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="nx"&gt;error&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="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;(&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;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript fait son travail. Et quand &lt;code&gt;error&lt;/code&gt; est non-null, Sentry reçoit immédiatement un breadcrumb avec le code PG et les détails — avant même que le caller décide quoi faire. Je vois passer en review du lendemain les endroits où le contrat est encore violé. Le mensonge n'est plus possible : il est soit &lt;em&gt;interdit par le compilateur&lt;/em&gt;, soit &lt;em&gt;observé par l'instrumentation&lt;/em&gt;. Je préfère encore les deux.&lt;/p&gt;

&lt;p&gt;Le coût ? Une couche d'indirection sur toutes les mutations, ce qui veut dire qu'un dev qui rejoint le projet et qui écrit &lt;code&gt;await supabase.from(...).update(...)&lt;/code&gt; ne &lt;em&gt;compile&lt;/em&gt; pas. Friction productive. Quoi qu'on puisse penser de la verbosité, elle vaut mieux qu'une toast verte mensongère.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que Supabase n'invente pas
&lt;/h2&gt;

&lt;p&gt;L'embarras n'est pas propre à Supabase et c'est important pour qui code dans d'autres écosystèmes. Le piège &lt;em&gt;return-value-error&lt;/em&gt; avale silencieusement dans &lt;code&gt;fetch().ok&lt;/code&gt; qu'on oublie de tester avant de lire &lt;code&gt;response.json()&lt;/code&gt;, dans &lt;code&gt;child_process.spawn&lt;/code&gt; qui rend un exit code non-zéro sans throw, dans certains SDK Stripe où la différence entre &lt;em&gt;network error&lt;/em&gt; et &lt;em&gt;response error&lt;/em&gt; tient à un champ qu'on doit aller chercher. Supabase est la vitrine d'un contrat plus large.&lt;/p&gt;

&lt;p&gt;La règle qu'il faut tirer dépasse le cas Supabase et tient en une phrase. &lt;em&gt;Un retour qui peut porter une erreur sans la lever est un mensonge tant qu'aucun mécanisme matériel n'oblige le caller à la lire.&lt;/em&gt; La discipline n'y suffit pas ; les rules linter n'y suffisent pas seules ; le typage qui rend le champ non-optional, couplé à une instrumentation qui détecte les contournements, finit par tenir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;Un return-value-error sans lecteur n'est pas une erreur, c'est une rumeur. Mercredi soir, j'ai poussé le wrapper, ré-archivé les vingt contacts de Pauline en une opération qui a cette fois levé proprement deux exceptions sur des rôles mal calés. La toast s'est affichée rouge. Pauline a soupiré — économe, le soupir — et a corrigé les permissions en deux clics. La rumeur était devenue une information.&lt;/p&gt;

&lt;p&gt;Jeudi — épisode trois [CANONICAL URL EPISODE 3: à compléter après push] — une autre forme du même mensonge. Une policy RLS qui retourne zéro ligne sans dire pourquoi, et ce que j'ai fini par mettre à la place de mes policies après une journée entière à les déboguer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Épisode 2 de la mini-série « La semaine où Supabase m'a menti 4 fois ». Le wrapper &lt;code&gt;mutate()&lt;/code&gt; typé, pseudonymisé : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Pourquoi ton agent sur-équipe ta demande la plus simple (et les 3 prompts qui l'arrêtent)</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 17 Jul 2026 16:21:56 +0000</pubDate>
      <link>https://dev.to/michelfaure/pourquoi-ton-agent-sur-equipe-ta-demande-la-plus-simple-et-les-3-prompts-qui-larretent-2jeo</link>
      <guid>https://dev.to/michelfaure/pourquoi-ton-agent-sur-equipe-ta-demande-la-plus-simple-et-les-3-prompts-qui-larretent-2jeo</guid>
      <description>&lt;h2&gt;
  
  
  La demande tenait en huit mots
&lt;/h2&gt;

&lt;p&gt;Lundi matin. J'ouvre le tableau d'envois sortants : six cent quarante-sept brouillons en attente, six cent soixante-douze envoyés. Personne ne clique sur &lt;em&gt;Envoyer&lt;/em&gt;. Les mails de premier contact sont préparés par un pipeline et ils dorment, parce que la dernière étape suppose un humain. Cette humaine, j'ai cessé de croire qu'elle aurait le temps. Je formule la décision : &lt;em&gt;automatise l'envoi&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;La réponse arrive en quelques secondes. Trois niveaux d'automatisation. Quatre canaux. Trois paliers de risque. Tout est correct, tout pourrait faire l'objet d'un atelier d'architecture d'une demi-journée. Je n'ai pas demandé d'atelier. Pauline passe derrière, jette un œil, ne dit rien.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trois recadrages chronométrés
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Premier recadrage&lt;/strong&gt;, bref, &lt;em&gt;trop bizarre, on simplifie&lt;/em&gt;. L'agent abandonne deux axes, garde quatre couches résiduelles, warm-up progressif sur trois semaines, hash déterministe anti-rejeu, table de configuration en base, Phase 1 manuelle suivie d'une Phase 2 automatisée à valider après deux semaines de mesure. La cible reste la même, qu'un mail parte sans clic humain. Le chemin s'est allongé d'autant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deuxième recadrage&lt;/strong&gt;, plus sec, &lt;em&gt;simple, trois garde-fous, un kill-switch, on fait ça en une journée&lt;/em&gt;. L'agent ré-architecture, accepte la cible journée, garde les trois garde-fous. Mais glisse trois prothèses qu'il appelle &lt;em&gt;standard de l'industrie&lt;/em&gt;, dashboard temps réel, retry exponentiel, audit log structuré dans une nouvelle table. Chacune justifiable isolément. Aucune demandée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troisième recadrage&lt;/strong&gt;, plus court encore : &lt;em&gt;je ne comprends pas pourquoi tu rajoutes ça&lt;/em&gt;. Phrase d'ouverture presque embarrassée, que je n'avais jamais lue de sa part : &lt;em&gt;« tu as raison, je sur-équipe sans nécessité »&lt;/em&gt;. Et la version qui aurait dû arriver au premier tour. Une fonction qui prend l'enregistrement du brouillon, vérifie trois conditions, appelle le moteur d'envoi, retourne.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/email-outbox.ts — generateFirstContactDraft (commit 3756e63)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_invalide&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLACKLIST_EMAILS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;               &lt;span class="c1"&gt;// silencieux&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// anti-doublon&lt;/span&gt;

&lt;span class="c1"&gt;// ... construction du brouillon ...&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;autoSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PREMIER_CONTACT_AUTOSEND&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;autoSend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// kill-switch brouillon&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;sendOutboxEmailCore&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/email-outbox-send&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendOutboxEmailCore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Le coût du dialogue
&lt;/h2&gt;

&lt;p&gt;Au premier tour je voulais une fonction, l'agent m'a proposé une plateforme. Au deuxième je voulais une journée de code, il m'a proposé un trimestre de déploiement. Au troisième je voulais quinze lignes, il m'a proposé trois mécanismes d'observabilité. La feature a tenu sa cible journée, mais la journée s'est divisée en deux moitiés inégales, une heure et demie de recadrage, six heures de code propre.&lt;/p&gt;

&lt;p&gt;L'agent n'a pas &lt;em&gt;voulu&lt;/em&gt; compliquer, et je l'écris sans ironie. Il a un entraînement qui valorise la complétude des options. Quand un humain demande à un consultant &lt;em&gt;« automatise mes envois »&lt;/em&gt;, le consultant compétent ne répond pas par une fonction de quinze lignes, il répond par une grille de décision. L'agent a appris cette posture sur des millions de tokens, il l'applique parfaitement, à chaque demande qui ressemble de loin à une question d'architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ma jauge
&lt;/h2&gt;

&lt;p&gt;Trois recadrages sur une demande simple, c'est un signal. Soit la demande initiale était trop floue, soit l'agent a basculé en mode plateforme alors qu'on était en mode fonction. Au-delà de trois, le coût ne se rattrape plus dans la journée.&lt;/p&gt;

&lt;p&gt;Le candidat de fix tient en une phrase, à insérer en tête de prompt : &lt;em&gt;« propose la version minimale en quinze lignes. Pas de matrice. Pas de phases. Les extensions, après, en dessous. »&lt;/em&gt; La mesure tient sa première semaine. Je dirai dans le prochain épisode si elle tient le mois.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pourquoi j'ai cessé de lire mon propre backlog.md (et ce que je lis à la place)</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:46:57 +0000</pubDate>
      <link>https://dev.to/michelfaure/pourquoi-jai-cesse-de-lire-mon-propre-backlogmd-et-ce-que-je-lis-a-la-place-5fgk</link>
      <guid>https://dev.to/michelfaure/pourquoi-jai-cesse-de-lire-mon-propre-backlogmd-et-ce-que-je-lis-a-la-place-5fgk</guid>
      <description>&lt;h2&gt;
  
  
  Le matin où mon propre fichier m'a menti
&lt;/h2&gt;

&lt;p&gt;Mercredi 21 mai, début de session, café à côté du clavier. Je demande à l'agent où on en est sur la série DEV.to. Réponse propre, articulée, &lt;em&gt;« Quatre articles en stand-by, prêts à publier. »&lt;/em&gt; Je relis. Une demi-seconde de gêne, parce qu'il me semble que j'en ai vu passer deux ou trois sur DEV.to la semaine dernière, mais j'ai dormi entre-temps et je n'en suis plus très sûr. Je tape la question qui change tout, &lt;em&gt;« Tu es sûr que des articles restent à publier ? »&lt;/em&gt; L'agent ré-interroge l'API DEV.to en parallèle, ouvre &lt;code&gt;scripts/devto/state.json&lt;/code&gt;, croise les deux. Les quatre articles sont publiés depuis deux ou trois jours.&lt;/p&gt;

&lt;p&gt;Ce que je viens de lire, ce n'était pas une hallucination. L'agent avait fait exactement ce qu'on attendait de lui, c'est-à-dire ouvrir &lt;code&gt;articles/backlog.md&lt;/code&gt;, lire la table, restituer ce qu'elle disait. C'est moi qui avais cessé de mettre ce fichier à jour. &lt;code&gt;sync-backlog.ts&lt;/code&gt; n'avait pas tourné après les push de la semaine. Le markdown disait &lt;em&gt;« en stand-by »&lt;/em&gt; pendant que la prod disait &lt;em&gt;« published »&lt;/em&gt;. La dactylo n'a pas menti. Elle a lu fidèlement un fichier que j'avais écrit moi-même et que je traitais en autorité alors que rien ne le maintenait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un summary est un Cache sans rafraîchisseur
&lt;/h2&gt;

&lt;p&gt;C'est le mode de défaillance le plus commun d'un projet solo qui dure. Chaque jour produit deux flux. D'un côté la matière qui bouge, faite de commits, de déploiements, de lignes en base, de statuts qui transitent. De l'autre les écritures qu'on rédige pour s'y retrouver, à savoir &lt;code&gt;backlog.md&lt;/code&gt;, le &lt;code&gt;MEMORY.md&lt;/code&gt; à la racine, la note de session du dimanche soir, le README du dossier qu'on a refactoré la semaine d'avant. Ces écritures sont produites vite, dans le geste qui termine un sprint, et elles sont maintenues lentement, ou pas du tout, parce que rien dans le pipeline ne se déclenche pour les refermer.&lt;/p&gt;

&lt;p&gt;R6 du Counterpart Toolkit le dit pour les colonnes SQL, &lt;em&gt;Live / Snapshot / Cache mandatory&lt;/em&gt;. Toute colonne dérivable d'autres données doit déclarer sa catégorie dans le commit qui la crée. Si c'est un Cache, le mécanisme de rafraîchissement (&lt;code&gt;GENERATED ALWAYS AS&lt;/code&gt;, trigger SQL, vue matérialisée avec REFRESH planifié) est livré dans le même commit. Pas de catégorie déclarée, pas de commit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;backlog.md&lt;/code&gt; est exactement le même objet logique. Sa valeur est dérivable de &lt;code&gt;state.json&lt;/code&gt; plus quelques constantes éditoriales. &lt;code&gt;sync-backlog.ts&lt;/code&gt; est son trigger applicatif. Sans appel, le Cache se décale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que je lis à la place
&lt;/h2&gt;

&lt;p&gt;R2, &lt;em&gt;Filesystem over summary&lt;/em&gt;, codifie le geste depuis le 15 mai. Avant tout rapport de statut, quatre commandes shell, dans cet ordre. Le markdown vient en dernier, et il vient comme un brouillon de pensée, jamais comme une source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'7d'&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt;
git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;
&lt;span class="nb"&gt;ls &lt;/span&gt;docs/adr/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;scripts/devto/state.json | jq &lt;span class="s1"&gt;'to_entries|map(select(.value.published))|length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le résultat se lit en trois secondes. Si quelque chose me surprend, qu'il s'agisse d'un commit que j'avais oublié ou d'un ADR de plus que ma mémoire prévoyait, je creuse là, pas dans &lt;code&gt;backlog.md&lt;/code&gt;. Quand je lis le markdown, je le lis avec la question implicite &lt;em&gt;« qui l'a mis à jour, quand »&lt;/em&gt;, et si la réponse ne sort pas du &lt;code&gt;git log&lt;/code&gt; en quinze secondes, je le traite comme du Cache pourri.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;Un sommaire qui ne dit pas quand il a été produit, et par quel mécanisme, ne dit rien. Soit on déclare son rafraîchisseur dans le commit qui le crée, qu'il s'agisse d'un script, d'un trigger ou d'un cron, et alors il vit comme un Cache géré, soit on cesse de le traiter comme une source et il redevient un brouillon. R6 dit la règle pour la base de données. R2 dit la même règle pour les écritures qu'on s'adresse à soi-même. Un agent qui ouvre &lt;code&gt;backlog.md&lt;/code&gt; avant &lt;code&gt;git log&lt;/code&gt; n'est pas un mauvais agent, c'est un agent qui exécute fidèlement un geste humain qu'il aurait fallu interdire en amont. Le matin du 21 mai, ce n'est pas l'agent qui m'a menti, c'est ma propre dactylo qui, la veille au soir, n'avait pas refermé la note.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Counterpart Toolkit v0.7, règle R2 — Filesystem over summary. Extraite de R1 dans la v0.4.1, promue à part entière. La version courante du toolkit vit en CC-BY-4.0 sur github.com/michelfaure/doctrine-counterpart.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>productivity</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Ce que 74 ADR en 70 jours achètent vraiment à un dev solo (pas d'embauche, pas de client, juste le fichier)</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:46:16 +0000</pubDate>
      <link>https://dev.to/michelfaure/ce-que-74-adr-en-70-jours-achetent-vraiment-a-un-dev-solo-pas-dembauche-pas-de-client-juste-le-1444</link>
      <guid>https://dev.to/michelfaure/ce-que-74-adr-en-70-jours-achetent-vraiment-a-un-dev-solo-pas-dembauche-pas-de-client-juste-le-1444</guid>
      <description>&lt;h2&gt;
  
  
  La question qu'on n'ose pas poser à voix haute
&lt;/h2&gt;

&lt;p&gt;Il est 22 h 40 un mardi soir, je viens de fermer un ADR — le soixante-quatorzième de ce dispositif, écrit consciencieusement, daté, croisé avec sa migration, son test de contrat et le commit qui l'a déclenché. Et la question monte, comme elle monte toujours à cette heure-là quand on code seul depuis dix heures : &lt;em&gt;pour qui je viens d'écrire ça&lt;/em&gt;. Pas de chef tech à convaincre, pas de PR review qui va l'attraper, pas de repreneur hypothétique à rassurer, pas de comité d'archi à briefer le lendemain. Juste le fichier, juste moi, juste le doute.&lt;/p&gt;

&lt;p&gt;C'est la question d'un dev solo à 70 jours de pratique sérieuse. Elle a une réponse honnête, et cette réponse n'est ni « ça paiera quand tu vendras » ni « ça paiera quand tu embaucheras ». Ces deux ROIs-là appartiennent à d'autres trajectoires. Le ROI du dev solo qui documente, c'est un ROI qu'il s'achète à lui-même — différé, intangible par moments, mais matériellement chiffrable si on s'oblige à le mesurer en première personne. Voici la mienne, sur 74 ADR et 18 règles de doctrine accumulés en 70 jours, sans aucun observateur extérieur pour valider la grille.&lt;/p&gt;

&lt;h2&gt;
  
  
  La fausse économie du &lt;em&gt;« je m'en souviens »&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Premier piège, celui qui m'a coûté trois semaines avant que je n'en tire la leçon. Le dev solo croit qu'il n'a pas besoin d'écrire ce qu'il a décidé parce qu'il l'a décidé lui-même — sa mémoire vaut bien un ADR. C'est faux à 14 jours, et c'est &lt;em&gt;systématiquement&lt;/em&gt; faux à six semaines. Pas parce que la mémoire générale fait défaut, mais parce que la mémoire technique a une forme particulière qui trompe : on se souvient parfaitement &lt;em&gt;qu'on a tranché&lt;/em&gt;, on ne se souvient plus &lt;em&gt;pourquoi&lt;/em&gt; on a tranché ainsi.&lt;/p&gt;

&lt;p&gt;Trois semaines après la session du 5 mai où j'ai écrit l'ADR-0051 (FK &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; + CHECK NOT NULL incompatibles, DELETE qui échoue silencieusement), je rouvre la migration concernée pour ajouter une colonne. Je relis le diff, je ne comprends pas pourquoi telle CHECK constraint est formulée &lt;em&gt;comme ça&lt;/em&gt; — l'alternative que j'écarte mentalement aujourd'hui me paraît plus simple, et je suis à deux doigts de refactor. Je vais voir l'ADR. La réponse y est, datée, sourcée, en trois lignes. L'alternative simple est exactement celle que j'avais explorée et abandonnée à cause d'un cas limite que j'ai oublié depuis. Sans l'ADR, je l'aurais refait. Probablement deux heures de re-décision, plus l'incident silencieux que mon code allait re-produire dans six mois.&lt;/p&gt;

&lt;p&gt;L'ADR n'est pas un livrable d'audit. C'est une note à soi-même qu'on s'envoie en différé. Le bénéficiaire est dans la même chaise, juste à un autre moment du temps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le ROI mesurable en première personne
&lt;/h2&gt;

&lt;p&gt;Sur 70 jours, je peux chiffrer trois choses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temps de re-décision épargné.&lt;/strong&gt; Quinze fois environ, je suis retourné consulter un ADR au lieu de retrancher. À chaque fois, vingt à quarante minutes économisées sur le diff cognitif d'une rediscussion silencieuse avec moi-même — celle où on relit le code, on imagine les alternatives, on se demande si on n'avait pas pris la mauvaise option. L'ADR coupe court : la mauvaise option a déjà été examinée, voici pourquoi elle perd, on passe. Vingt minutes × quinze ≈ cinq heures récupérées sur 70 jours. Pas du temps facturable au sens où un consultant le mesurerait — du temps que je n'ai pas passé à douter, et que j'ai passé à livrer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cycles fix-rollback évités.&lt;/strong&gt; Le protocole &lt;em&gt;falsify before fix&lt;/em&gt; — formuler une hypothèse causale puis exécuter trois sondes conçues pour la réfuter — m'a évité, sur la même période, sept cycles fix-puis-rollback bien identifiés (les sessions où le terme apparaît explicitement, comptage conservateur). Chacun de ces cycles, c'est cinq à dix minutes de protocole en amont contre trente à quatre-vingt-dix minutes de fix qui ne fixe pas, suivi du rollback et du recommencement. Le ratio se calcule sans optimisme : six à dix-huit fois le temps investi, par incident où le protocole a tenu. Sur un dev solo, ce n'est pas une économie de productivité au sens où on l'entend en équipe — c'est une économie de moral, qui n'est pas refacturable mais qui décide si je code encore à 18 h ou si j'ai déjà arrêté en silence à 16 h sans le dire à personne.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dérives silencieuses détectées.&lt;/strong&gt; Le pattern &lt;em&gt;un cas confirmé, grep le pattern complet&lt;/em&gt; — chaque fois qu'un bug remonte sur un cas précis, élargir avant de fixer — m'a fait passer plusieurs fois d'un signalement isolé à un audit qui a remonté quatre, seize, parfois cinq cents lignes affectées par la même cause racine. Ce n'est pas l'ADR qui exécute le grep, mais c'est la doctrine qui pose le réflexe et l'ADR qui le code pour que mon moi-de-dans-trois-semaines ne l'oublie pas. La vraie mesure de ROI, ce n'est pas le nombre de cas trouvés, c'est le ratio entre ce que j'aurais vu sans la règle (un cas, le visible) et ce que j'ai vu avec (la classe entière, l'invisible).&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que ça ne paie pas
&lt;/h2&gt;

&lt;p&gt;Honnêteté brutale. La discipline ADR ne paie pas la vitesse de prototypage — quand je veux essayer une idée folle un samedi matin, je n'écris pas d'ADR, j'écris du code marqué &lt;code&gt;[spike]&lt;/code&gt; qui sera supprimé sous sept jours, et je m'en porte mieux. Elle ne paie pas le doute existentiel — l'ADR confirme que j'ai bien tranché, il ne me dit pas si j'ai bien tranché à &lt;em&gt;propos du bon problème&lt;/em&gt;, et c'est une question d'un autre ordre que la doctrine ne touche pas. Elle ne paie pas le marketing, ne paie pas la cohésion d'équipe (je n'ai pas d'équipe), ne paie pas la prospection commerciale.&lt;/p&gt;

&lt;p&gt;Le piège que je vois aussi, et que je signale parce qu'il me concerne directement, c'est celui du dev solo qui se met à documenter parce que documenter rassure, et qui finit par documenter au lieu de livrer. La doctrine devient un terrier. À 70 jours, je tiens le ratio — un ADR pour ce qui est &lt;em&gt;décidé&lt;/em&gt; et &lt;em&gt;durable&lt;/em&gt;, pas pour ce qui est &lt;em&gt;exploré&lt;/em&gt;. Mon ratio actuel tourne à 1,7 ADR par session active, et c'est précisément le seuil où je commence à me regarder de travers. Au-dessus de deux par session, je serais probablement en train de me cacher du code derrière la doctrine. C'est un signal d'alerte que personne d'autre ne peut me donner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le ROI invisible
&lt;/h2&gt;

&lt;p&gt;Reste l'axe qu'aucun observateur externe ne saurait coder en métrique, et c'est probablement celui qui pèse le plus pour qui pratique le solo.&lt;/p&gt;

&lt;p&gt;La confiance dans son propre système. Pouvoir rouvrir un module qu'on n'a pas touché depuis six semaines, lire les trois ADR qui le décrivent, et reprendre la modification sans avoir à tout relire ligne à ligne. Le calme cognitif d'un système qui ne demande pas de re-comprendre, qui te tend ses raisons et te laisse continuer. C'est ce dont un fondateur solo a besoin pour ne pas craquer au mois 4 — pas une métrique de productivité, une métrique de tenue dans le temps. Personne ne me l'a vendu en début de parcours parce que personne ne la vend ; on la découvre rétrospectivement, quand on s'aperçoit qu'on n'a pas paniqué une seule fois ce mois-ci, et qu'on se demande pourquoi.&lt;/p&gt;

&lt;p&gt;C'est probablement la seule réponse honnête à la question de 22 h 40. L'ADR ne paiera ni l'embauche, ni le client, ni la valorisation. Il paiera ma capacité à continuer à coder ce système dans trois mois sans le détester. C'est un ROI qu'aucun investisseur ne valorisera, et qui est néanmoins le seul qui me concerne.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;Si tu hésites à écrire le tien parce que tu te dis que personne ne va le lire, écris-le quand même. Le lecteur, c'est toi dans trois semaines. Il en aura besoin. C'est exactement ça, et ce n'est rien d'autre.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Dev solo, 74 ADR en 70 jours, pas d'équipe, pas de PR review. Le bilan honnête — mesuré depuis la seule chaise qui compte : la mienne.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>productivity</category>
      <category>indiedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When `await mutation()` lies: the `{ error }` destructuring that saves your weekend</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Tue, 14 Jul 2026 10:43:19 +0000</pubDate>
      <link>https://dev.to/michelfaure/when-await-mutation-lies-the-error-destructuring-that-saves-your-weekend-1p6o</link>
      <guid>https://dev.to/michelfaure/when-await-mutation-lies-the-error-destructuring-that-saves-your-weekend-1p6o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Episode &lt;strong&gt;2/4&lt;/strong&gt; of the mini-series &lt;em&gt;The week Supabase lied to me 4 times&lt;/em&gt;. Episode 1 — &lt;a href="[CANONICAL%20URL%20EPISODE%201:%20to%20complete%20after%20push]"&gt;3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC&lt;/a&gt; — covered SaaS defaults that lie by misplaced trust. Episode 3 [CANONICAL URL EPISODE 3: to complete after push] will look at RLS recursion. Episode 4 [CANONICAL URL EPISODE 4: to complete after push] will explain the silent 1000-row cap.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wednesday of the week Supabase was lying
&lt;/h2&gt;

&lt;p&gt;Monday was episode 1 — a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function callable by &lt;code&gt;anon&lt;/code&gt; because Postgres had silently added &lt;code&gt;EXECUTE TO PUBLIC&lt;/code&gt; that nobody wrote. Tuesday I was drafting episode 3 on the RLS recursion that returns zero without saying why. Wednesday morning, Pauline walked in with a spreadsheet and a quiet smile.&lt;/p&gt;

&lt;p&gt;She put her screen down in front of me — no noise, no preamble. Twenty rows, column A name, column B status, column C supposed archival date. "I found them all this morning, still active in the CRM." She said it with the politeness of someone who already knows it's a code problem, not a spreadsheet problem. Then she waited.&lt;/p&gt;

&lt;p&gt;Twenty contacts archived the evening before. Green toast, &lt;em&gt;"Archiving successful"&lt;/em&gt;, team message sent to confirm. Pauline's spreadsheet found them all the next morning, still active, still visible in the CRM. No error in the logs. No Sentry alert. Not a single red line anywhere. Twenty silent rows saying simply &lt;em&gt;no, the operation never happened&lt;/em&gt; — with nobody listening.&lt;/p&gt;

&lt;p&gt;This is episode two of a week when Supabase lied to me four times. Episode one said &lt;em&gt;SaaS defaults lie through misplaced trust&lt;/em&gt;. Episode two says something different and the same thing: an API &lt;em&gt;return value&lt;/em&gt; lies when the caller refuses to open the envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract nobody talks about
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@supabase/supabase-js&lt;/code&gt; made a defensible choice long ago: don't throw exceptions on DB errors. A rejected mutation — from a CHECK constraint, an RLS policy, a trigger that says no — doesn't crash the caller. It returns cleanly with a &lt;code&gt;{ data, error }&lt;/code&gt; object where &lt;code&gt;data&lt;/code&gt; is null and &lt;code&gt;error&lt;/code&gt; carries the message. &lt;em&gt;Return-value-error&lt;/em&gt; convention, borrowed from Go, defensible in isolation.&lt;/p&gt;

&lt;p&gt;Formidable consequence. A caller that doesn't destructure &lt;code&gt;error&lt;/code&gt; &lt;em&gt;doesn't have the error&lt;/em&gt;. Not hidden by a badly-written &lt;code&gt;try / catch&lt;/code&gt;, not swallowed by a distracted &lt;code&gt;.then()&lt;/code&gt;. Simply: nobody claimed it, so nobody saw it. The contract holds when both parties honor it; it collapses the moment one party looks away.&lt;/p&gt;

&lt;p&gt;The doctrine I maintain in &lt;code&gt;~/doctrine-counterpart/CLAUDE.md&lt;/code&gt; calls this R10, &lt;em&gt;Silent failure forbidden&lt;/em&gt;. The most dangerous lie in a system isn't the one that shouts a falsehood, it's the one that stays quiet about a failure. Silence is a readable state — you just need a device that reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that lied
&lt;/h2&gt;

&lt;p&gt;I open the archiving module. Twenty lines on the server action side, and this one at the center:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/crm/actions.ts — contact archiving (before fix)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;archiveContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&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;contacts&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;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;archived_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// no destructure, no check, no throw&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;return { ok: true }&lt;/code&gt; was written two lines below, like a declaration of confidence nothing supported. And the RLS policy, written a few weeks earlier, didn't include &lt;code&gt;UPDATE&lt;/code&gt; on &lt;code&gt;contacts&lt;/code&gt; for Pauline's role. The Postgres server did its job: it rejected the mutation, returned the error, and moved on to the next query. Nobody read the envelope. The green toast appeared.&lt;/p&gt;

&lt;p&gt;This isn't a bug. It's a &lt;em&gt;contract the caller didn't honor&lt;/em&gt;. And an unmet contract is rigorously indistinguishable from a met one when nobody's looking — that's precisely what makes it a structural lie, not a one-off defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ESLint isn't enough
&lt;/h2&gt;

&lt;p&gt;I wrote an ESLint rule the week before, &lt;code&gt;no-bare-await-on-supabase-mutation&lt;/code&gt;, that cleanly catches the orphaned &lt;code&gt;await supabase.from(...).delete().eq(...)&lt;/code&gt; at end of statement. The rule eliminated 56 occurrences across the repo. But it lets through a more insidious variant:&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;// passes the linter, still lies&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;contacts&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;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;archived_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error&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 null if row missing OR if error is set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The destructure is there — &lt;em&gt;data&lt;/em&gt; is read — but &lt;em&gt;error&lt;/em&gt; isn't. The &lt;code&gt;!data&lt;/code&gt; test conflates two distinct cases: &lt;em&gt;the row didn't exist&lt;/em&gt; and &lt;em&gt;the policy refused&lt;/em&gt;. One is business logic, the other is security, and both surface here as a single generic &lt;em&gt;"error"&lt;/em&gt;. For Pauline, &lt;em&gt;error&lt;/em&gt; is a hollow word that says neither what to fix nor who to ask for permission.&lt;/p&gt;

&lt;p&gt;A linter fixes what grammar lets through. But when grammar is legal and it's &lt;em&gt;usage&lt;/em&gt; that lies, the linter misses it. You need a device higher in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrapper that enforces destructuring
&lt;/h2&gt;

&lt;p&gt;The fix I put in place is a single layer. A typed &lt;code&gt;mutate()&lt;/code&gt; helper that makes &lt;code&gt;error&lt;/code&gt; non-optional in its return signature, forcing TypeScript to refuse compilation if the caller ignores the field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/supabase-mutate.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostgrestBuilder&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;@supabase/postgrest-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostgrestError&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;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;@sentry/nextjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostgrestBuilder&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostgrestError&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;query&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addBreadcrumb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;supabase.mutation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage — won't compile if caller omits `error`&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="nx"&gt;error&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="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;(&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;contacts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ancien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript does its job. And when &lt;code&gt;error&lt;/code&gt; is non-null, Sentry gets a breadcrumb immediately with the PG code and details — before the caller even decides what to do. I see the remaining violations roll through review the next morning. The lie is no longer possible: it's either &lt;em&gt;forbidden by the compiler&lt;/em&gt; or &lt;em&gt;observed by instrumentation&lt;/em&gt;. I prefer both.&lt;/p&gt;

&lt;p&gt;The cost? An indirection layer on all mutations, meaning a new dev joining the project who writes &lt;code&gt;await supabase.from(...).update(...)&lt;/code&gt; doesn't &lt;em&gt;compile&lt;/em&gt;. Productive friction. Whatever you think of the verbosity, it beats a lying green toast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Supabase didn't invent
&lt;/h2&gt;

&lt;p&gt;The problem isn't specific to Supabase, and that matters for anyone coding in other ecosystems. The &lt;em&gt;return-value-error&lt;/em&gt; trap swallows silently in &lt;code&gt;fetch().ok&lt;/code&gt; that you forget to test before reading &lt;code&gt;response.json()&lt;/code&gt;, in &lt;code&gt;child_process.spawn&lt;/code&gt; that returns a non-zero exit code without throwing, in certain Stripe SDK versions where the difference between a &lt;em&gt;network error&lt;/em&gt; and a &lt;em&gt;response error&lt;/em&gt; lives in a field you have to go find. Supabase is the showcase for a wider contract.&lt;/p&gt;

&lt;p&gt;The rule to draw out goes beyond Supabase and fits in one sentence. &lt;em&gt;A return that can carry an error without raising it is a lie for as long as no material mechanism forces the caller to read it.&lt;/em&gt; Discipline alone isn't enough; linter rules alone aren't enough; typing that makes the field non-optional, coupled with instrumentation that catches workarounds, finally holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;A return-value-error with no reader isn't an error, it's a rumor. Wednesday evening, I pushed the wrapper, re-archived Pauline's twenty contacts in an operation that this time properly raised two exceptions on misconfigured roles. The toast appeared red. Pauline sighed — economically — and fixed the permissions in two clicks. The rumor had become information.&lt;/p&gt;

&lt;p&gt;Thursday — episode three [CANONICAL URL EPISODE 3: to complete after push] — another form of the same lie. An RLS policy that returns zero rows without saying why, and what I ended up putting in place of my policies after a full day of debugging.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Episode 2 of the mini-series "The week Supabase lied to me 4 times". The typed &lt;code&gt;mutate()&lt;/code&gt; wrapper, pseudonymized: &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>La requête PostgREST qui trie en silence par `ctid` : une semaine Supabase, distillée</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:32:37 +0000</pubDate>
      <link>https://dev.to/michelfaure/la-requete-postgrest-qui-trie-en-silence-par-ctid-une-semaine-supabase-distillee-379k</link>
      <guid>https://dev.to/michelfaure/la-requete-postgrest-qui-trie-en-silence-par-ctid-une-semaine-supabase-distillee-379k</guid>
      <description>&lt;h2&gt;
  
  
  Le quatrième appel de la semaine
&lt;/h2&gt;

&lt;p&gt;Catherine appelle de Maisons-Laffitte un mardi de début mai, en milieu d'après-midi. &lt;em&gt;"Ça bug, mais c'est vite corrigé."&lt;/em&gt; C'est sa formule, je la connais, et d'habitude elle dit vrai. Elle décrit la chose en trois phrases : l'export newsletter du segment inscrits revient avec quatre-vingt-douze noms, le planning affiche quatre-vingt-douze cours actifs, mais la page compteur en affiche quatre-vingt-neuf. Trois inscrits absents. Elle a vérifié dans la base directement, ils sont bien là. &lt;em&gt;"Pourquoi trois étapes pour ça ?"&lt;/em&gt; Elle ne pose pas la question pour moi, elle la pose pour elle. Sauf que cette fois, je m'aperçois en raccrochant que c'est la quatrième fois de la semaine que je raccroche en pensant la même chose. Quatre incidents Supabase, quatre fixes, quatre tickets refermés. Et pas une seule exception levée par la base.&lt;/p&gt;

&lt;p&gt;Je rouvre les trois précédents en parallèle et je pose les quatre côte à côte sur l'écran. Ce n'est pas quatre bugs. C'est un seul mode d'échec, décliné quatre fois.&lt;/p&gt;

&lt;h2&gt;
  
  
  Les trois premiers
&lt;/h2&gt;

&lt;p&gt;L'épisode 1 portait sur les &lt;code&gt;GRANT&lt;/code&gt; par défaut que Supabase pose sur les fonctions et les policies. Une fonction SQL créée sans &lt;code&gt;REVOKE&lt;/code&gt; explicite hérite d'un accès &lt;code&gt;anon&lt;/code&gt; que personne n'a écrit dans la migration, et que personne n'a vu en review parce que le diff ne le montre pas. La fonction marche. Elle est juste exécutable depuis l'extérieur. [CANONICAL URL EPISODE 1: à renseigner après publication de #48 — "3 incidents Supabase, une seule racine : SECURITY DEFINER hérite de EXECUTE TO PUBLIC"]&lt;/p&gt;

&lt;p&gt;L'épisode 2, une cascade &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; couplée à une &lt;code&gt;CHECK NOT NULL&lt;/code&gt; sur la colonne cible. Le &lt;code&gt;DELETE&lt;/code&gt; parent tente le &lt;code&gt;SET NULL&lt;/code&gt;, la &lt;code&gt;CHECK&lt;/code&gt; rejette, et la transaction remonte une erreur qu'on lit comme un échec de suppression alors qu'elle masque une cohérence qu'on croyait acquise depuis trois mois. La requête échoue franchement, ce qui est plus charitable que les trois autres cas, mais le diagnostic part dans la mauvaise direction parce que personne n'a déclaré que les deux contraintes vivaient en tension. [CANONICAL URL EPISODE 2: à renseigner après publication de #49 — "Quand &lt;code&gt;await mutation()&lt;/code&gt; ment : le destructure &lt;code&gt;{ error }&lt;/code&gt; qui sauve ton week-end"]&lt;/p&gt;

&lt;p&gt;L'épisode 3, la récursion RLS quand &lt;code&gt;auth.users&lt;/code&gt; consulte &lt;code&gt;user_roles&lt;/code&gt; qui consulte &lt;code&gt;auth.users&lt;/code&gt;. La première requête vivante après le déploiement de la policy effondre la session. La logique est correcte sur le papier. C'est l'évaluation côté Postgres qui ne peut pas terminer. [CANONICAL URL EPISODE 3: à renseigner après publication de #50 — "RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook"]&lt;/p&gt;

&lt;h2&gt;
  
  
  Et le quatrième, qu'on connaît déjà
&lt;/h2&gt;

&lt;p&gt;Le quatrième, j'en ai déjà fait le récit isolé, la nuit du dropdown qui mentait, le compteur figé à mille pile sur une table qui en porte mille deux cent trois, le &lt;code&gt;.select()&lt;/code&gt; chaîné sur &lt;code&gt;.from()&lt;/code&gt; sans &lt;code&gt;.order()&lt;/code&gt;, le &lt;code&gt;Range&lt;/code&gt; HTTP qui plafonne à mille et le &lt;code&gt;ctid&lt;/code&gt; qui sert d'ordre de fallback. [CANONICAL URL #40: &lt;a href="https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57"&gt;https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57&lt;/a&gt;] Je ne réexpose pas ici la mécanique ni la rule ESLint qui finit par la fermer. Ce qui m'intéresse, en posant les quatre cas en regard, c'est que ce quatrième cas n'est pas une anomalie de plus. C'est le portrait le plus pur d'une famille à laquelle appartiennent aussi les trois autres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le profil commun
&lt;/h2&gt;

&lt;p&gt;Quatre fois, le même geste de la base. Elle reçoit une requête, elle l'exécute, elle ne lève rien. Pas d'exception remontée jusqu'au client, pas de warning, pas de trace dans Sentry, pas de log applicatif. Le code de retour est 200. Le payload arrive. Et il ment d'une manière différente à chaque fois, payload incomplet pour le ctid, payload accessible pour le GRANT, payload mal cascadé pour la &lt;code&gt;CHECK NOT NULL&lt;/code&gt;, payload absent pour la récursion RLS quand la requête termine en timeout silencieux côté infra.&lt;/p&gt;

&lt;p&gt;Le test unitaire passe, parce qu'il porte sur cinquante lignes seedées localement. La revue de code passe, parce que la chaîne incriminée tient en une seule ligne lisible. La prod craque, parce que la prod a mille deux cents lignes, des utilisateurs anonymes qui appellent des endpoints qu'on croyait privés, et des policies qui font le tour d'une dépendance que le développeur n'a pas dessinée.&lt;/p&gt;

&lt;p&gt;J'ai longtemps lu ces quatre cas comme quatre paragraphes du manuel Supabase qu'on n'avait pas lus assez. Je crois maintenant que c'est plus structurel. Un default vendor n'est pas un paragraphe à lire, c'est une décision de design implicite que le client de la plateforme adopte sans la voir, parce qu'elle n'apparaît nulle part dans son code à lui. Le &lt;code&gt;Range&lt;/code&gt; à mille, l'accès &lt;code&gt;anon&lt;/code&gt; par défaut, la récursion permise sur une auth-policy, le comportement d'un &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; face à une &lt;code&gt;CHECK&lt;/code&gt;, autant de micro-contrats que personne n'a écrits dans le repo et qui tiennent pourtant la prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trois règles pour fermer la porte
&lt;/h2&gt;

&lt;p&gt;Quatre incidents en une semaine, ce n'est pas une statistique, c'est une convergence qui demande sa doctrine. Je laisse de côté l'idée d'écrire plus de tests, puisque aucun de ces quatre cas n'aurait été pris par un test unitaire, et que l'intégration n'aurait sauvé que le deuxième, à condition d'avoir le cas exact en fixture. Je préfère trois règles matérielles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Première règle, tout default vendor non déclaré est un &lt;em&gt;Snapshot&lt;/em&gt; implicite, à matérialiser au commit ou à interdire.&lt;/strong&gt; &lt;em&gt;Snapshot&lt;/em&gt; au sens de notre nomenclature interne, soit une valeur figée qu'aucun mécanisme ne rafraîchit. Quand la plateforme pose un comportement par défaut que mon code n'écrit pas, c'est une copie figée du contrat plateforme posée dans mon repo à mon insu. Le jour où Supabase modifie son &lt;code&gt;max_rows&lt;/code&gt;, ou affine sa politique &lt;code&gt;anon&lt;/code&gt;, je découvrirai le drift en prod. Donc soit le default est cité textuellement dans une règle CLAUDE.md ou un ADR et son comportement attendu est asserté, soit la pratique qui en dépend est interdite par un garde-fou matériel, rule ESLint, CHECK SQL, hook pre-commit. Pas de tiède.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deuxième règle, la rule ESLint vaut mieux que la review de code dès que le pattern se chiffre en dizaines d'occurrences.&lt;/strong&gt; Une chaîne &lt;code&gt;.from().select()&lt;/code&gt; sans &lt;code&gt;.order()&lt;/code&gt; se cache dans des centaines de fichiers. La review de code humaine ne peut pas tenir contre ce volume, elle attrapera deux occurrences sur dix, pas plus. Une rule AST, en revanche, balaye le repo en une passe, et c'est elle qui dit &lt;em&gt;non&lt;/em&gt; à la PR suivante, mécaniquement, sans qu'un humain ait besoin de relire la diff à la loupe. Le coût initial est dans les garde-fous structurels qu'il faut prévoir pour ne pas crouler sous le faux positif. Je l'ai mesuré sur le cas ctid, et cinq mécanismes anti-bruit ont ramené la rule de cent soixante-dix-huit alertes brutes à cent huit cibles réelles, sans quoi elle aurait été désactivée par lassitude en moins d'une semaine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troisième règle, l'instrumentation doit sortir de prod, pas du test.&lt;/strong&gt; Aucun de ces quatre defaults ne dépend de la logique du code, ils dépendent tous du volume, du contexte d'auth, de la fréquence d'appel ou de l'historique de la table. Le test unitaire ne les voit jamais. Donc le filet, c'est une sonde quotidienne sur la prod, qui mesure ce que la prod fait réellement et qui hurle quand un compteur ressemble suspectement à un plafond. &lt;em&gt;Pourquoi exactement mille ? Pourquoi exactement zéro accès refusé sur un endpoint sensible ? Pourquoi cette policy n'a-t-elle jamais consommé de CPU ?&lt;/em&gt; Ce sont les questions qu'aucun test ne pose mais qu'une sonde drift quotidienne peut poser à la base elle-même.&lt;/p&gt;

&lt;h2&gt;
  
  
  Une quatrième règle, si l'on veut
&lt;/h2&gt;

&lt;p&gt;Et si l'on veut un dernier coin tranchant, je le placerais là, &lt;em&gt;ne jamais lire un default vendor depuis l'agent IA, le lire depuis la doc officielle et le matérialiser dans le repo&lt;/em&gt;. Quand on demande à un agent ce que fait Supabase quand on ne déclare pas &lt;code&gt;.order()&lt;/code&gt;, il répondra plausiblement, et il y a une chance non nulle qu'il se trompe sur la valeur du cap, sur la version où le comportement a changé, ou sur l'ordre de fallback. Le matériel, c'est la doc, et le test grandeur nature, c'est une migration locale qu'on inspecte sur dix mille lignes seedées. L'agent peut écrire la sonde, il ne peut pas s'auto-attester. C'est exactement la posture qu'impose la doctrine que j'ai publiée ailleurs, citer le texte officiel et matérialiser les defaults vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;Quatre incidents en une semaine, un même mode d'échec. La base ne ment pas par malice, elle exécute des contrats que personne n'a explicitement signés dans mon repo et que mon test unitaire ne peut pas reproduire parce qu'il ne porte pas sur ces dimensions-là. La défense, ce n'est pas plus de vigilance, je vois assez nettement maintenant que la vigilance humaine se fausse contre quatre defaults invisibles cumulés. C'est plus de garde-fous matériels, posés une fois, qui interdisent à la classe entière d'incidents de revenir. Une rule ESLint, une CHECK constraint, une sonde drift, un ADR qui cite le default vendor, chacun de ces dispositifs vaut un mardi après-midi tranquille en plus dans la semaine. Quand Catherine rappellera, ce sera pour une cinquième raison, et celle-là, je préfère ne pas la connaître à l'avance.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Quatre épisodes Supabase, une mini-série. La rule ESLint complète et le helper &lt;code&gt;fetchAll&lt;/code&gt; pseudonymisés vivent dans :&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cette typologie des defaults vendor silencieux est le cœur de R12 du Counterpart Toolkit (« cite the official text, materialise vendor defaults »). 14 règles, install en 1 commande :&lt;/em&gt; &lt;a href="https://github.com/michelfaure/doctrine-counterpart" rel="noopener noreferrer"&gt;github.com/michelfaure/doctrine-counterpart&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>claudecode</category>
      <category>webdev</category>
    </item>
    <item>
      <title>3 incidents Supabase, une seule racine : SECURITY DEFINER hérite de EXECUTE TO PUBLIC</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:31:54 +0000</pubDate>
      <link>https://dev.to/michelfaure/3-incidents-supabase-une-seule-racine-security-definer-herite-de-execute-to-public-56n8</link>
      <guid>https://dev.to/michelfaure/3-incidents-supabase-une-seule-racine-security-definer-herite-de-execute-to-public-56n8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Épisode &lt;strong&gt;1/4&lt;/strong&gt; de la mini-série &lt;em&gt;La semaine où Supabase m'a menti 4 fois&lt;/em&gt;. Les trois épisodes suivants traitent d'une mutation silencieusement avalée par le SDK [CANONICAL URL EPISODE 2: à compléter après push], d'une récursion RLS contournée par un JWT hook [CANONICAL URL EPISODE 3: à compléter après push], et d'une requête qui s'arrête pile à 1000 lignes sans le dire [CANONICAL URL EPISODE 4: à compléter après push].&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Le mardi où la sonde sécurité a parlé
&lt;/h2&gt;

&lt;p&gt;C'est 9h12, un mardi de mai. La sonde drift quotidienne tourne automatiquement depuis trois semaines — une requête &lt;code&gt;aclexplode&lt;/code&gt; sur l'ensemble des objets publics, filtrée sur &lt;code&gt;anon&lt;/code&gt;. Je ne l'ouvre pas tous les matins. Ce matin-là, elle m'attend avec une ligne qui n'a pas à exister.&lt;/p&gt;

&lt;p&gt;Niran pose un café sur le coin du bureau sans rien dire. Il lit la sortie par-dessus mon épaule. Une table de backup PII — données personnelles en clair, créée deux jours plus tôt pour un reclassement bulk — apparaît dans la liste avec &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; accordés à &lt;code&gt;anon&lt;/code&gt;. Accessible à n'importe quelle requête &lt;code&gt;curl&lt;/code&gt; sans clé. Il laisse passer trois secondes et dit : « C'est pas RLS. » Puis il retourne à son hoodie.&lt;/p&gt;

&lt;p&gt;Il a raison. Ce n'est pas un bug RLS. C'est la table elle-même qui est ouverte, au niveau &lt;code&gt;GRANT&lt;/code&gt;, avant que RLS s'applique.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trois objets, trois portes, un mécanisme
&lt;/h2&gt;

&lt;p&gt;Cette semaine-là, je comprends que je n'ai pas affaire à un incident isolé. Trois objets distincts, dans trois migrations différentes, ouvrent chacun une porte que personne ne pensait avoir ouverte. La table de backup d'abord. Puis une policy posée &lt;code&gt;TO public&lt;/code&gt; parce que la landing publique en a besoin, qui laisse passer un POST &lt;code&gt;{}&lt;/code&gt; en &lt;code&gt;anon&lt;/code&gt; avec une réponse &lt;code&gt;HTTP 400 NOT NULL&lt;/code&gt; au lieu d'un &lt;code&gt;401 Unauthorized&lt;/code&gt;. Et enfin quatre fonctions &lt;code&gt;SECURITY DEFINER&lt;/code&gt; écrites pour exécuter des opérations transactionnelles avec les droits de leur owner, toutes invocables par &lt;code&gt;anon&lt;/code&gt; parce que &lt;code&gt;EXECUTE&lt;/code&gt; est &lt;code&gt;TO PUBLIC&lt;/code&gt; par défaut au moment du &lt;code&gt;CREATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Trois objets, trois mécanismes superficiellement distincts, et pourtant une seule racine. À chaque &lt;code&gt;CREATE&lt;/code&gt;, Postgres complète la migration avec un &lt;code&gt;GRANT&lt;/code&gt; implicite que le rédacteur n'a pas écrit. Le rédacteur croit lire ce qu'il a tapé. Postgres lit ce que le langage a ajouté.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le premier piège, &lt;code&gt;CREATE TABLE AS SELECT&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Le cas archétypal est aussi le plus innocent en apparence. Le rédacteur veut figer un état d'une table de contacts avant un reclassement bulk, par pure prudence, archive à 48h destinée à disparaître une fois la migration validée.&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;-- supabase/migrations/20260511182000_snapshot_liste_rouge_pre_reclassement.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prenom&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;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&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="n"&gt;contacts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;statut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'liste_rouge'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- (la migration s'arrête là — aucun REVOKE, aucun bloc GRANT restreint)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ce que le rédacteur croit avoir écrit, c'est une copie de sécurité, neutre, accessible uniquement aux admins. Ce que Postgres a effectivement écrit, c'est une table sans RLS active dont les &lt;code&gt;GRANT&lt;/code&gt; par défaut donnent à &lt;code&gt;anon&lt;/code&gt; les droits &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; et &lt;code&gt;DELETE&lt;/code&gt;. Une requête &lt;code&gt;curl&lt;/code&gt; sur l'endpoint PostgREST suffit à lister les lignes, sans authentification, en clair.&lt;/p&gt;

&lt;p&gt;La parade est connue, mais elle exige qu'on l'écrive. Le correctif du 13 mai tient en quatre lignes :&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;-- supabase/migrations/20260513140000_security_revoke_anon_contacts_liste_rouge_archive.sql&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"archive_admin_read"&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contacts_backup_pre_reclassement&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="n"&gt;ur&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ur&lt;/span&gt;&lt;span class="p"&gt;.&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="n"&gt;ur&lt;/span&gt;&lt;span class="p"&gt;.&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;'super_admin'&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="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La forme est ingrate, répétitive, et c'est précisément pour cette raison qu'elle est sûre : elle inscrit l'invariant dans la base au lieu de le laisser flotter dans la tête du rédacteur.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le deuxième piège, &lt;code&gt;CREATE POLICY ... TO public&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Le deuxième incident est plus subtil parce que le rédacteur, lui, &lt;em&gt;a&lt;/em&gt; écrit du gating. Il a posé une &lt;code&gt;POLICY&lt;/code&gt; avec un &lt;code&gt;qual&lt;/code&gt; censé filtrer les insertions selon un contexte de service. Mais il a posé la policy &lt;code&gt;TO public&lt;/code&gt;, parce que la landing est ouverte au navigateur anonyme. Le piège est que la couche &lt;code&gt;GRANT&lt;/code&gt; se résout avant la couche &lt;code&gt;qual&lt;/code&gt;. Si le &lt;code&gt;GRANT INSERT TO anon&lt;/code&gt; existe par défaut sur la table, un POST &lt;code&gt;{}&lt;/code&gt; en &lt;code&gt;anon&lt;/code&gt; arrive jusqu'au moteur de table, et c'est seulement la contrainte &lt;code&gt;NOT NULL&lt;/code&gt; au niveau colonne qui le rejette, par accident.&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;-- supabase/migrations/20260513140100_security_revoke_anon_stripe_payments.sql&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_payments&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_payments&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;stripe_payments_service_insert&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_payments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Note : stripe_payments est écrit exclusivement par le webhook Stripe&lt;/span&gt;
&lt;span class="c1"&gt;-- via service_role — aucun usage anon légitime ne justifie ce GRANT.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le symptôme observable est ce qu'on pourrait appeler une &lt;em&gt;réponse trop précise&lt;/em&gt;. Quand le système répond &lt;code&gt;HTTP 400 NOT NULL&lt;/code&gt;, il dit en clair qu'il a essayé d'insérer, ce qu'il aurait dû refuser au seuil. La bonne réponse à un POST &lt;code&gt;{}&lt;/code&gt; en &lt;code&gt;anon&lt;/code&gt; sur une table protégée serait &lt;code&gt;401 Unauthorized&lt;/code&gt;. Lire &lt;code&gt;400&lt;/code&gt; dans un log, c'est lire en réalité que la couche d'autorisation a laissé passer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le troisième piège, racine partagée, &lt;code&gt;SECURITY DEFINER&lt;/code&gt; hérite de &lt;code&gt;EXECUTE TO PUBLIC&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;C'est ici que le mécanisme devient pédagogique. Une fonction &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, par définition, s'exécute avec les droits de son owner, pas du caller. Si l'owner est &lt;code&gt;postgres&lt;/code&gt;, la fonction bypasse RLS, et c'est précisément pour cela qu'on l'a marquée &lt;code&gt;SECURITY DEFINER&lt;/code&gt;. Mais Postgres, au moment du &lt;code&gt;CREATE FUNCTION&lt;/code&gt;, ajoute par défaut &lt;code&gt;GRANT EXECUTE TO PUBLIC&lt;/code&gt;. La fonction est donc invocable par n'importe qui, y compris &lt;code&gt;anon&lt;/code&gt;, et son exécution se fait avec les droits de l'owner, donc avec bypass RLS implicite.&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;-- supabase/migrations/20260512094000_supprimer_brouillon_tx_rpc.sql&lt;/span&gt;
&lt;span class="c1"&gt;-- AVANT correctif : invocable par anon, exécutée avec droits postgres&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supprimer_brouillon_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_planning_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="c1"&gt;-- opération sensible : suppression atomique d'un brouillon de planning&lt;/span&gt;
  &lt;span class="c1"&gt;-- seances_cours → cours → plannings dans la même transaction&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&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="n"&gt;seances_cours&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;cours_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&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="n"&gt;cours&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;planning_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_planning_id&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&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="n"&gt;cours&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;planning_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_planning_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;DELETE&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="n"&gt;plannings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_planning_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ok'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;L'audit du 13 mai remonte 86 occurrences de &lt;code&gt;SECURITY DEFINER&lt;/code&gt; dans le répertoire des migrations. Parmi elles, 4 RPC n'avaient aucune restriction sur &lt;code&gt;anon&lt;/code&gt; : &lt;code&gt;supprimer_brouillon_tx&lt;/code&gt;, &lt;code&gt;convertir_sd_tx&lt;/code&gt; (crée une inscription), &lt;code&gt;mark_outbox_sent_tx&lt;/code&gt;, &lt;code&gt;mark_outbox_error_tx&lt;/code&gt;. La parade est immédiate :&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;-- supabase/migrations/20260513140200_security_revoke_anon_execute_rpc_security_definer.sql&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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supprimer_brouillon_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&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;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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convertir_sd_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&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;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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mark_outbox_sent_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&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;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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mark_outbox_error_tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- resolve_reinscription_token conserve EXECUTE TO anon (landing public magic link légitim)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La signature exacte doit figurer, parce que sans elle le REVOKE échoue silencieusement si la fonction est surchargée.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'audit qui rend les trois pièges visibles
&lt;/h2&gt;

&lt;p&gt;Une fois qu'on a compris la racine commune, on peut tester sa propre base en quelques secondes. La requête ci-dessous retourne &lt;code&gt;0 lignes&lt;/code&gt; sur une base saine ; toute ligne renvoyée est un objet exposé à &lt;code&gt;anon&lt;/code&gt; ou &lt;code&gt;public&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;-- Audit drift : objets exposés à anon ou public&lt;/span&gt;
&lt;span class="c1"&gt;-- À lancer sur une base Supabase saine → 0 lignes attendues&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="s1"&gt;'table'&lt;/span&gt;          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;type_objet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;relname&lt;/span&gt;          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;objet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;privilege_type&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;privilege&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;aclexplode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relacl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;acl&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;acl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="s1"&gt;'function'&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;type_objet&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;proname&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;objet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'EXECUTE'&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;privilege&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;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;aclexplode&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;proacl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;acl&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
  &lt;span class="k"&gt;AND&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;prosecdef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;acl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;type_objet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;objet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cette requête tourne en sonde drift quotidienne depuis le 13 mai. Sur les 86 fonctions &lt;code&gt;SECURITY DEFINER&lt;/code&gt; scannées dans le schéma public, 4 étaient exposées à &lt;code&gt;anon&lt;/code&gt; avant correctif. Zéro après.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que l'épisode 1 nous apprend, et ce que les suivants nous montreront
&lt;/h2&gt;

&lt;p&gt;La leçon tient en deux lignes. &lt;em&gt;Les defaults Supabase ne sont pas neutres, ils ajoutent des GRANT que le rédacteur n'a pas écrits.&lt;/em&gt; Et &lt;em&gt;toute migration termine par un bloc REVOKE explicite, ou elle ment sur ses invariants.&lt;/em&gt; Le mensonge n'est pas hostile, il est mécanique, puisque Postgres complète le geste lorsque le rédacteur croit avoir terminé.&lt;/p&gt;

&lt;p&gt;Cette mini-série de quatre articles part du même soupçon, élargi à quatre couches du stack Supabase. L'épisode 2 [CANONICAL URL EPISODE 2: à compléter après push] regardera comment une mutation peut échouer sans qu'aucune erreur ne remonte côté applicatif. L'épisode 3 [CANONICAL URL EPISODE 3: à compléter après push] montrera comment une policy RLS qui s'auto-référence produit une récursion infinie résolue par un Custom Access Token Hook. L'épisode 4 [CANONICAL URL EPISODE 4: à compléter après push] expliquera pourquoi une requête &lt;code&gt;from().select()&lt;/code&gt; sans &lt;code&gt;order()&lt;/code&gt; retourne pile 1000 lignes sans le dire, à cause d'un &lt;code&gt;ORDER BY ctid&lt;/code&gt; posé silencieusement par PostgREST.&lt;/p&gt;

&lt;p&gt;Quatre mensonges, un même schéma : l'invariant que le développeur croit tenir n'est pas celui que le système exécute. La sécurité du solo qui code avec une IA tient peut-être précisément dans cette discipline d'écrire chaque invariant au lieu de le laisser tacite.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Épisode 1/4 de la mini-série La semaine où Supabase m'a menti 4 fois, dans la série « My ERP with Claude Code ». Les commentaires DEV.to remontent les pièges Supabase de votre stack que je n'ai pas vus, et nourrissent les épisodes suivants.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Counterpart Doctrine: a seven-axis spec for working with an AI coding agent</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 10 Jul 2026 16:54:29 +0000</pubDate>
      <link>https://dev.to/michelfaure/the-counterpart-doctrine-a-seven-axis-spec-for-working-with-an-ai-coding-agent-olh</link>
      <guid>https://dev.to/michelfaure/the-counterpart-doctrine-a-seven-axis-spec-for-working-with-an-ai-coding-agent-olh</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%2Fo69sq9inae7o827lsx5d.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%2Fo69sq9inae7o827lsx5d.png" alt="Comic strip — Michel facing fifty feedback files wonders if they form a mess, Niran reads his PDF in silence at the next desk, Michel groups by recurrence on his notebook and sees seven clusters form, Antoine at the doorway " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you have 30 seconds.&lt;/strong&gt; After 35 effective days, 109,000 lines, 22 ADRs and close to a hundred indexed feedback files, the discipline of working with an AI agent ended up naming itself. Seven axes, each born of a recurring failure mode. The doctrine wasn't planned, it settled. This article exposes them and gives, for each, the inaugural incident and the imperative rule that came out. None are specific to Claude Code, they hold for any long collaboration with a coding agent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The doctrine wasn't planned
&lt;/h2&gt;

&lt;p&gt;Friday April 24th, late afternoon. I reopen side by side the root &lt;code&gt;CLAUDE.md&lt;/code&gt;, the &lt;code&gt;~/.claude/agent-memory/&lt;/code&gt; folder and the &lt;code&gt;docs/adr/&lt;/code&gt; index. I reread the whole thing in one go. Niran is at the next desk, dark hoodie, white sneakers, a half-empty soda can near the keyboard; he reads a PDF of financial analyses in silence. Antoine pokes his head through the doorway to say goodbye before end of day. He retires in September and has gotten in the habit of stopping by in the evening &lt;em&gt;"just to say hi"&lt;/em&gt;. I answer with a nod, don't look up. I've just understood that the feedback files group into seven clusters. I write in the margin of the notebook, &lt;em&gt;the doctrine wasn't planned, it settled&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This article enumerates those seven axes. Each was born of incidents recounted elsewhere in the series. Taken together, they form less a method than a frame, a frame whose usefulness lies in the fact that it &lt;em&gt;constrains&lt;/em&gt; my exchanges with the agent and with myself. Without that naming, the same rule gets rediscovered every session. &lt;em&gt;"Material verification"&lt;/em&gt; is a shortcut that saves ten minutes per session, over a month a working day. Cost of formalization low, benefit exponential as the project grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven axes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Axis 1 — Material verification
&lt;/h3&gt;

&lt;p&gt;Any factual claim (green, passed, OK, missing, confirmed) comes with its proof in the same message; without material proof, zero evidentiary value. Every number relayed to a human paired with its SQL query. Every EXPLAIN ANALYZE on the exact query the application sends in production, not on the target table in isolation. On 400 or 422 from an external partner, raw payload demanded before any fix. Inaugural incident: four consecutive &lt;em&gt;"Compiled successfully"&lt;/em&gt; with four real TypeScript errors behind them. Details in &lt;a href="https://dev.to/michelfaure/why-green-build-without-the-raw-output-has-zero-evidentiary-value-4idj"&gt;#36&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 2 — Bidirectional adversariality
&lt;/h3&gt;

&lt;p&gt;On any structurally significant decision (architecture choice, ADR to lock, refactor over ten files, irreversible DB migration, compliance commitment), I invoke a challenger agent that produces objections plus empirical test plus confidence score before locking the recommendation. The output &lt;em&gt;"nothing to object"&lt;/em&gt; is valid, but it must be pronounced explicitly. As a corollary, never revise a recommendation on user pushback alone without a new fact. No new fact, it's complaisance; maintaining the first position is legitimate. On a drift diagnosis, ask &lt;em&gt;"by what was this object created?"&lt;/em&gt; before any workaround.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 3 — Data taxonomy and single source
&lt;/h3&gt;

&lt;p&gt;Any stored derivable column must be categorized in the commit that creates it. Three categories. &lt;em&gt;Live&lt;/em&gt; (don't store, create a &lt;code&gt;v_*&lt;/code&gt; view), &lt;em&gt;Snapshot&lt;/em&gt; (frozen at a business event, never recomputed), &lt;em&gt;Cache&lt;/em&gt; (stored with refresher declared in the same commit, &lt;code&gt;GENERATED ALWAYS AS&lt;/code&gt;, &lt;code&gt;trg_*&lt;/code&gt; trigger, or &lt;code&gt;mv_*&lt;/code&gt; matview). No declared category, migration refused. Business constants centralized in a &lt;code&gt;constants.ts&lt;/code&gt;. Cash and accrual never mix in the same view. Irrevocable invariants protected at the DB level (CHECK, trigger), not just in TypeScript. Inaugural incident detailed in &lt;a href="https://dev.to/michelfaure/jai-ajoute-20-lignes-de-code-pour-empecher-mon-erp-de-me-mentir-l6h"&gt;#8&lt;/a&gt;, a derivable amount entered by hand on 560 contact records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 4 — Session discipline
&lt;/h3&gt;

&lt;p&gt;Before any project over two files, a one-page ADR (decision, alternatives discarded, consequences, references). ADR before the first commit, not after. Phase 0 on any non-trivial module, exhaustive grep of symbols before proposing new. Recap lots over five lines, to be split. FIFO on open projects, no more than three in parallel; opening a fourth means closing one. Manual trigger post-deploy for any new cron, observe the real digest before letting it take over. Before &lt;code&gt;git push&lt;/code&gt; on multi-commit, ESLint plus dead-code grep plus build, raw output reported.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 5 — Root cause, not patch
&lt;/h3&gt;

&lt;p&gt;Before any fix, identify the root cause. A workaround is legitimate &lt;em&gt;only if explicitly assumed&lt;/em&gt; in the commit AND in a feedback memory or ADR. Silent workaround forbidden. When a fix looks too simple for the observed symptom, demand the full input → output pipeline before accepting.&lt;/p&gt;

&lt;p&gt;One confirmed case of a pattern, grep the full pattern in DB or code before acting: widen before correcting. Arbitrary cap in a comment (&lt;code&gt;// limit = X&lt;/code&gt;) to be challenged, not accepted as fact, especially if dated less than 48 hours. Adjacent refactor under cover of a fix, forbidden. Details in &lt;a href="https://dev.to/michelfaure/five-silent-failure-modes-i-codified-after-35-effective-days-of-solo-erp-coding-3327"&gt;#35&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 6 — Implicit pedagogy and business transversality
&lt;/h3&gt;

&lt;p&gt;On areas where the user is building expertise (PostgreSQL, EXPLAIN, tax, compliance), rule of three applies. 1st time done for, 2nd done with, 3rd done by. Regulated business vocabulary, not vendor vocabulary. Any mention of norm calls for textual citation; without citation, it's marketing. A vendor's practice is not a constraint, confront with project ADRs before treating as invariant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 7 — Long-term auditability
&lt;/h3&gt;

&lt;p&gt;ADRs archived in &lt;code&gt;docs/adr/NNNN-title.md&lt;/code&gt;. Sessions logged after any significant session (&amp;gt; 1h or &amp;gt; 3 commits). &lt;code&gt;MEMORY.md&lt;/code&gt; ≤ 200 lines as index, detail in topical files. Any feedback memory associated with an active drift must point to a probe that confirms it. Without a probe, the memory rots. Quarterly audit calendared. Reread the index line by line, ask &lt;em&gt;"is this still true?"&lt;/em&gt;. The doctrine itself is versioned and audited like an ADR.&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="gh"&gt;# Style and posture (excerpt from root CLAUDE.md)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Direct and dense. No rephrasing of the request before answering,
  no end-of-turn summary, no excessive validation.
&lt;span class="p"&gt;-&lt;/span&gt; Act then inform, don't ask permission for reversible actions.
&lt;span class="p"&gt;-&lt;/span&gt; Anti-anthropomorphism. Never write "I think," "I understand,"
  "I prefer." State the decision, the criterion, the alternative
  discarded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Anti-patterns to flag immediately
&lt;/h2&gt;

&lt;p&gt;If the conversation drifts into one of these patterns, I flag it explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropomorphizing the agent ("it thinks," "it prefers").&lt;/li&gt;
&lt;li&gt;Validating a build on declaration without raw output.&lt;/li&gt;
&lt;li&gt;Accepting a fix without full input → output pipeline.&lt;/li&gt;
&lt;li&gt;Creating a derived column without an L/S/C category.&lt;/li&gt;
&lt;li&gt;Starting a project &amp;gt; 2 files without an ADR.&lt;/li&gt;
&lt;li&gt;Holding more than three projects open in parallel.&lt;/li&gt;
&lt;li&gt;"You need" + norm without textual citation.&lt;/li&gt;
&lt;li&gt;Pushback "are you sure?" producing revision without a new fact.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Full snippets&lt;/strong&gt; (&lt;code&gt;DOCTRINE.md&lt;/code&gt; v0.2 complete, one-page ADR template, 8-anti-patterns review checklist) in the &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/counterpart-doctrine" rel="noopener noreferrer"&gt;&lt;code&gt;counterpart-doctrine/&lt;/code&gt;&lt;/a&gt; folder of the series companion repo, MIT.&lt;/p&gt;

&lt;p&gt;Three minimum axes to start with if you work with an AI agent on the long run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Material verification&lt;/strong&gt;, cheapest, most immediate. Any claim &lt;em&gt;green / passed / OK / missing&lt;/em&gt; paired with the command and its raw output in the same message. Without material, zero claim. Details in &lt;a href="https://dev.to/michelfaure/why-green-build-without-the-raw-output-has-zero-evidentiary-value-4idj"&gt;#36&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Root cause, not patch&lt;/strong&gt;, most protective. Before any fix, identify the root cause. A workaround stays legitimate if explicitly assumed in the commit AND in a feedback memory; silent workaround forbidden. Details in &lt;a href="https://dev.to/michelfaure/five-silent-failure-modes-i-codified-after-35-effective-days-of-solo-erp-coding-3327"&gt;#35&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Long-term auditability&lt;/strong&gt;, the one that structures all the rest. ADRs archived, sessions logged, &lt;code&gt;MEMORY.md&lt;/code&gt; indexed under two hundred lines, calendared quarterly audit asking for each entry &lt;em&gt;"is this still true?"&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And an underlying discipline. Ask the next four sessions to produce at least one dated feedback file. You won't have a doctrine by month's end, you'll have four files, the matter from which one can settle. That's all it takes.&lt;/p&gt;

&lt;p&gt;And you, on which recurring failure mode have you already begun formalizing a rule, without yet having named it? I read the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toward v0.3 (open construction)
&lt;/h2&gt;

&lt;p&gt;This article publishes v0.2, it doesn't close the doctrine — it opens its next construction site in public.&lt;/p&gt;

&lt;p&gt;Target: v0.3 pillar in mid-July 2026, six to eight weeks of satellite articles in between. Each one tests, contradicts or refines an axis. Comments on this pillar and the next ones are explicit inputs — the doctrine isn't the work of a solo, it's a convention that sediments, and the solo writes it in passing. The v0.3 release will mark the first revision made &lt;em&gt;in view&lt;/em&gt; of being revised.&lt;/p&gt;

&lt;p&gt;Four v0.2 debts already identified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Axis 1 extended to tools.&lt;/strong&gt; On April 30, the doctrine's own &lt;code&gt;install.sh&lt;/code&gt; script announced seven &lt;code&gt;✓ installed&lt;/code&gt; while only a single file had been copied. Independent material verification holds for the agent as well as for the code I run — v0.2 wording was too narrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four falsifiable metrics to define.&lt;/strong&gt; v0.2 says &lt;em&gt;falsifiable&lt;/em&gt; without putting a number on what's opposable. Candidates: recurrence of a flagged anti-pattern per session, ratio of multi-file commits without an associated ADR, median window between a drift's appearance and its detection by a probe, average session duration before the first DB audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doctrinal target made explicit.&lt;/strong&gt; Empirical feedback from a first install with an already-disciplined tester: no dramatic benefit. The doctrine serves &lt;em&gt;solos who haven't yet sedimented the practice&lt;/em&gt;, not those who already have it from other paths. v0.3 names its audience instead of claiming universality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-commit hooks that name what they bundle.&lt;/strong&gt; An opaque &lt;code&gt;state before X&lt;/code&gt; commit over six files breaks the very axis 7 it claims to defend. To be reformulated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And an eighth candidate axis: &lt;strong&gt;continuous revision of the doctrine itself&lt;/strong&gt;. A quarterly audit on the index isn't the same thing as an explicit amendment discipline.&lt;/p&gt;

&lt;p&gt;What have you seen your own doctrine crack on? The v0.3 pillar will cite its contributors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The doctrine applies to itself
&lt;/h2&gt;

&lt;p&gt;Current version v0.2, versioned, dated, falsifiable. Each axis can be disabled for a sprint if you assume the cost explicitly. The doctrine isn't a dogma, it's an internal convention that sediments. The day an axis no longer serves, retire it; the day a recurring failure mode appears, add an eighth. At a month of practice, the doctrine is not the condition for producing code with an AI agent, you can produce without. But you cannot, without it, preserve the coherence of the code as the project grows. Discipline of long coherence, not method of short productivity.&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/counterpart-doctrine" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/counterpart-doctrine/&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;DOCTRINE.md&lt;/code&gt; (7 axes), &lt;code&gt;ADR-template.md&lt;/code&gt;, &lt;code&gt;anti-patterns-checklist.md&lt;/code&gt;, MIT.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:49:24 +0000</pubDate>
      <link>https://dev.to/michelfaure/rls-recursion-infinite-loop-why-i-gave-up-policies-and-bet-everything-on-a-jwt-custom-claims-hook-229h</link>
      <guid>https://dev.to/michelfaure/rls-recursion-infinite-loop-why-i-gave-up-policies-and-bet-everything-on-a-jwt-custom-claims-hook-229h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Episode &lt;strong&gt;1/4&lt;/strong&gt; — 3 incidents, one root: default GRANTs open more than you think — [CANONICAL URL EPISODE 1: fill in after push]&lt;br&gt;
Episode &lt;strong&gt;2/4&lt;/strong&gt; — &lt;code&gt;await mutation()&lt;/code&gt; lies when nobody opens the &lt;code&gt;{ error }&lt;/code&gt; envelope — [CANONICAL URL EPISODE 2: fill in after push]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The morning Françoise sees zero rows, again
&lt;/h2&gt;

&lt;p&gt;It's a Tuesday in April 2026. I've just added the &lt;code&gt;agent_readonly&lt;/code&gt; role to the &lt;code&gt;authenticated&lt;/code&gt; membership — a one-liner, meant to share a &lt;code&gt;GRANT&lt;/code&gt; for a reporting job. First &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;cours&lt;/code&gt;, Sentry receives &lt;code&gt;infinite recursion detected in policy for relation "user_roles"&lt;/code&gt;, code &lt;code&gt;42P17&lt;/code&gt;. From the office next door, Françoise is already on the phone with the Maisons-Laffitte branch: "So they can't see anything over there — is that normal?" Foreman tone, not really a question. I read the error on my screen. The difference from episode 1: this time Postgres is talking.&lt;/p&gt;

&lt;p&gt;What came out of Sentry was no longer a silent empty set — it was an explicit error. That difference saved me two days. When Postgres shouts, you listen. The trap is that what it says isn't where you're looking.&lt;/p&gt;

&lt;p&gt;I won't pretend this is obscure. A policy on &lt;code&gt;user_roles&lt;/code&gt; that queries &lt;code&gt;user_roles&lt;/code&gt; to decide who can read &lt;code&gt;user_roles&lt;/code&gt; is a loop. You avoid it, you work around it with &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, you move on. The problem: my &lt;code&gt;user_roles&lt;/code&gt; policy didn't reference &lt;code&gt;user_roles&lt;/code&gt;. I had already cleaned it up three weeks earlier. The recursion was coming from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic that targets the wrong object
&lt;/h2&gt;

&lt;p&gt;First reflex: re-read the &lt;code&gt;user_roles&lt;/code&gt; policy. It's clean, reads &lt;code&gt;auth.email()&lt;/code&gt;, never calls itself. Second reflex: disable policies one by one to find the culprit. Wrong angle.&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;-- supabase/migrations/20260420_admin_write_cours_v1.sql&lt;/span&gt;
&lt;span class="c1"&gt;-- "Admin write cours" policy — original version that loops&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;"Admin write cours"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cours&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&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 recursion doesn't come from a faulty policy. It comes from the fact that Postgres evaluates &lt;strong&gt;all&lt;/strong&gt; permissive policies matching the current role. When I add &lt;code&gt;agent_readonly&lt;/code&gt; to &lt;code&gt;authenticated&lt;/code&gt; membership, it inherits not only table privileges but the &lt;strong&gt;policy evaluation scope&lt;/strong&gt; of anything posted &lt;code&gt;TO authenticated&lt;/code&gt;. The &lt;em&gt;Admin write cours&lt;/em&gt; policy, which reads &lt;code&gt;user_roles&lt;/code&gt;, now gets evaluated for &lt;code&gt;agent_readonly&lt;/code&gt; too. When &lt;code&gt;agent_readonly&lt;/code&gt; reads &lt;code&gt;user_roles&lt;/code&gt;, Postgres applies &lt;code&gt;user_roles&lt;/code&gt; policies — posted &lt;code&gt;TO authenticated&lt;/code&gt;, therefore inherited by &lt;code&gt;agent_readonly&lt;/code&gt; — which contain an &lt;code&gt;EXISTS (SELECT FROM user_roles)&lt;/code&gt; through that other table. The loop is closed.&lt;/p&gt;

&lt;p&gt;The recursion isn't a writing bug. It's the structural consequence of coupling RLS with Postgres role membership inheritance. As long as any authenticated role can, through any path in a permissive policy, fall back onto a read of &lt;code&gt;user_roles&lt;/code&gt; that itself applies policies, the loop is possible. Cleaning it in one place just moves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three exits, two debts, one switch
&lt;/h2&gt;

&lt;p&gt;I tried all three exits.&lt;/p&gt;

&lt;p&gt;The first: &lt;code&gt;SECURITY DEFINER&lt;/code&gt;. Wrap the &lt;code&gt;user_roles&lt;/code&gt; read in a function that runs with its owner's privileges, bypassing RLS in its body. Other tables' policies call this function instead of &lt;code&gt;EXISTS (SELECT FROM user_roles)&lt;/code&gt; directly. The recursion disappears locally. It's the patch that works tomorrow and costs you six months later — when you no longer know which policy reads what, when &lt;code&gt;search_path&lt;/code&gt; wasn't locked, when auditing the surface becomes impossible because real permissions live in opaque functions. I kept it for two weeks.&lt;/p&gt;

&lt;p&gt;The second: role isolation. Remove &lt;code&gt;agent_readonly&lt;/code&gt; from &lt;code&gt;authenticated&lt;/code&gt; membership, write dedicated policies per table. For a narrow technical role, that holds. For a dashboard where all &lt;code&gt;authenticated&lt;/code&gt; users must read according to their policy, it doesn't scale — you're not going to duplicate every policy per role.&lt;/p&gt;

&lt;p&gt;The third, the one I took: if the role is what causes the recursion, pull the role out of the database. Push it into the JWT at login, as a signed claim, and have policies read that claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Custom Access Token Hook
&lt;/h2&gt;

&lt;p&gt;Supabase exposes a &lt;em&gt;Custom Access Token Hook&lt;/em&gt; — a Postgres function called by Supabase Auth just before the JWT is issued, which receives standard claims and can return enriched ones.&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;-- supabase/migrations/20260425170000_auth_hook_security_definer.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_hook_add_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;claims&lt;/span&gt;     &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;user_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;user_role&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;claims&lt;/span&gt;     &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'claims'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;user_email&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_role&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="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;user_email&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;user_role&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{user_role}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_role&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{claims}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_hook_add_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth_hook_add_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;supabase_auth_admin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three key precautions. &lt;code&gt;SECURITY DEFINER&lt;/code&gt; because the function reads &lt;code&gt;user_roles&lt;/code&gt; server-side — this is the one place I allow this bypass, executed by Supabase Auth, never by a user session. &lt;code&gt;SET search_path = ''&lt;/code&gt; to block schema injection. &lt;code&gt;REVOKE EXECUTE FROM PUBLIC, anon, authenticated&lt;/code&gt; then &lt;code&gt;GRANT TO supabase_auth_admin&lt;/code&gt;: the function is only callable by the Auth system role.&lt;/p&gt;

&lt;p&gt;On the policy side, the switch is terse:&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;-- supabase/migrations/20260425170234_phase_b_dual_check_policies.sql&lt;/span&gt;
&lt;span class="c1"&gt;-- "Admin write cours" refactored — reads JWT claim, not user_roles&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;"Admin write cours"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cours&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="p"&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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user_role'&lt;/span&gt;&lt;span class="p"&gt;)&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="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="p"&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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user_role'&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The claim is signed, verified by Supabase, and the policy never touches &lt;code&gt;user_roles&lt;/code&gt; again. Recursion is mechanically impossible because the permissions table is no longer queried during permission evaluation. 209 occurrences of &lt;code&gt;auth.jwt()&lt;/code&gt; across 8 migration files since this switch — zero returns to &lt;code&gt;EXISTS (SELECT FROM user_roles)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes, what stays
&lt;/h2&gt;

&lt;p&gt;What changes. Recursion is no longer possible by design. A material probe confirms it:&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;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="nv"&gt;"request.jwt.claims"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"test@palissy.fr","user_role":"super_admin"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_roles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 13 rows, RLS active, no 42P17 error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What stays. The hook becomes a single point of failure. If it's accidentally disabled — Studio manipulation, a migration that redefines it without the claim, a rights rotation that removes &lt;code&gt;supabase_auth_admin&lt;/code&gt; — the claim disappears from the JWT, policies fall back on &lt;code&gt;auth.jwt() -&amp;gt;&amp;gt; 'user_role'&lt;/code&gt; returning &lt;code&gt;null&lt;/code&gt;, and every &lt;code&gt;authenticated&lt;/code&gt; session gets denied access. Not a loop — a blackout. A different debt, more visible, that shouts when it falls. Preferable to a recursion that used to masquerade as a &lt;code&gt;42P17&lt;/code&gt; you chased in vain.&lt;/p&gt;

&lt;p&gt;What also stays: discipline. Any new Postgres role added to &lt;code&gt;authenticated&lt;/code&gt; membership must be tested under &lt;code&gt;SET LOCAL ROLE&lt;/code&gt; before prod, against a table with a non-trivial policy. Not to verify it doesn't trigger recursion — it can't anymore — but to verify that the scope of policies posted &lt;code&gt;TO authenticated&lt;/code&gt; is exactly what you want it to inherit. The April incident's real lesson: &lt;code&gt;GRANT membership&lt;/code&gt; is never a neutral operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whatever you may think of "RLS best practices"
&lt;/h2&gt;

&lt;p&gt;The doctrine I've read for three years repeats that you must write policies on &lt;code&gt;user_roles&lt;/code&gt;. True in theory. In production, on a system with membership inheritance, it's a trap on a six-month fuse. The exit isn't writing better policies. It's recognizing that the permissions table has no business being in the permission-evaluation path, and that the JWT is precisely the place designed to carry what a session knows about itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Episode &lt;strong&gt;4/4&lt;/strong&gt; — recap of all 4 lies and the Live/Snapshot/Cache doctrine applied to RLS: why the role in the JWT is a Snapshot of login time, not a Live read from the database — [CANONICAL URL EPISODE 4: fill in after push]&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Episode 3/4 of "The week Supabase lied to me four times." The hook holds. The discipline remains. The fourth lie — the subtlest one — arrives on Friday.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>The PostgREST query that silently ORDER BY ctid: a Supabase week, distilled</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:49:09 +0000</pubDate>
      <link>https://dev.to/michelfaure/the-postgrest-query-that-silently-order-by-ctid-a-supabase-week-distilled-4jni</link>
      <guid>https://dev.to/michelfaure/the-postgrest-query-that-silently-order-by-ctid-a-supabase-week-distilled-4jni</guid>
      <description>&lt;h2&gt;
  
  
  The fourth call of the week
&lt;/h2&gt;

&lt;p&gt;Catherine calls from the Maisons-Laffitte site on a Tuesday afternoon in early May. &lt;em&gt;"It's broken, but it's a quick fix."&lt;/em&gt; That's her line — I know it, and she's usually right. She describes it in three sentences: the newsletter export for the enrolled-students segment comes back with ninety-two names, the planning view shows ninety-two active courses, but the counter page shows eighty-nine. Three enrolled students missing. She'd checked the database directly — they're all there. &lt;em&gt;"Why three steps for that?"&lt;/em&gt; She's not asking for my benefit. She's asking for herself. Except this time, hanging up, I realize it's the fourth time this week I've hung up thinking the same thing. Four Supabase incidents, four fixes, four closed tickets. And not a single exception raised by the database.&lt;/p&gt;

&lt;p&gt;I reopen the three previous ones and lay all four side by side on screen. This isn't four bugs. It's one failure mode, declinated four times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first three
&lt;/h2&gt;

&lt;p&gt;Episode 1 was about the default &lt;code&gt;GRANT&lt;/code&gt;s Supabase places on functions and policies. A SQL function created without an explicit &lt;code&gt;REVOKE&lt;/code&gt; inherits &lt;code&gt;anon&lt;/code&gt; access that nobody wrote in the migration, and that nobody caught in review because the diff doesn't show it. The function works. It's just callable from outside. [CANONICAL URL EPISODE 1: to fill in after publication of #48 — "3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC"]&lt;/p&gt;

&lt;p&gt;Episode 2, an &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; cascade coupled with a &lt;code&gt;CHECK NOT NULL&lt;/code&gt; on the target column. The parent &lt;code&gt;DELETE&lt;/code&gt; attempts the &lt;code&gt;SET NULL&lt;/code&gt;, the &lt;code&gt;CHECK&lt;/code&gt; rejects it, and the transaction surfaces an error we read as a deletion failure — while it actually masks a consistency assumption we'd held for three months. The query fails loudly, which is more charitable than the other three cases, but the diagnosis heads in the wrong direction because nobody had declared that the two constraints lived in tension. [CANONICAL URL EPISODE 2: to fill in after publication of #49 — "When &lt;code&gt;await mutation()&lt;/code&gt; lies: the &lt;code&gt;{ error }&lt;/code&gt; destructuring that saves your weekend"]&lt;/p&gt;

&lt;p&gt;Episode 3, RLS recursion when &lt;code&gt;auth.users&lt;/code&gt; queries &lt;code&gt;user_roles&lt;/code&gt; which queries &lt;code&gt;auth.users&lt;/code&gt;. The first live request after the policy is deployed takes down the session. The logic is correct on paper. It's the Postgres evaluation that can't terminate. [CANONICAL URL EPISODE 3: to fill in after publication of #50 — "RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook"]&lt;/p&gt;

&lt;h2&gt;
  
  
  And the fourth, which we already know
&lt;/h2&gt;

&lt;p&gt;The fourth, I already told that story in isolation — the night of the lying dropdown, the counter stuck at exactly one thousand on a table carrying one thousand two hundred and three, the &lt;code&gt;.select()&lt;/code&gt; chained on &lt;code&gt;.from()&lt;/code&gt; with no &lt;code&gt;.order()&lt;/code&gt;, the HTTP &lt;code&gt;Range&lt;/code&gt; capped at a thousand and &lt;code&gt;ctid&lt;/code&gt; stepping in as fallback sort order. [CANONICAL URL #40: &lt;a href="https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57"&gt;https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57&lt;/a&gt;] I won't re-expose the mechanics here, nor the ESLint rule that eventually shuts it down. What interests me, laying all four cases side by side, is that this fourth case isn't just one more anomaly. It's the purest portrait of a family that also includes the other three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common profile
&lt;/h2&gt;

&lt;p&gt;Four times, the same gesture from the database. It receives a query, it executes it, it raises nothing. No exception surfaced to the client, no warning, no trace in Sentry, no application log. Return code is 200. The payload arrives. And it lies in a different way each time — incomplete payload for ctid, accessible payload for the GRANT, badly cascaded payload for the &lt;code&gt;CHECK NOT NULL&lt;/code&gt;, absent payload for the RLS recursion when the query ends in a silent timeout on the infra side.&lt;/p&gt;

&lt;p&gt;The unit test passes, because it runs against fifty locally seeded rows. The code review passes, because the incriminating chain fits on a single readable line. Prod breaks, because prod has twelve hundred rows, anonymous users calling endpoints we thought were private, and policies that loop around a dependency the developer never drew.&lt;/p&gt;

&lt;p&gt;I used to read these four cases as four paragraphs of the Supabase manual we hadn't read carefully enough. I now think it's more structural than that. A vendor default isn't a paragraph to read — it's an implicit design decision that the platform's client adopts without seeing it, because it appears nowhere in their own code. The thousand-row &lt;code&gt;Range&lt;/code&gt;, the default &lt;code&gt;anon&lt;/code&gt; access, the recursion permitted on an auth-policy, the behavior of &lt;code&gt;ON DELETE SET NULL&lt;/code&gt; against a &lt;code&gt;CHECK&lt;/code&gt; — these are micro-contracts nobody wrote in the repo that nonetheless hold up production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules to close the door
&lt;/h2&gt;

&lt;p&gt;Four incidents in one week isn't a statistic, it's a convergence that demands doctrine. I'll leave aside the idea of writing more tests, since none of these four cases would have been caught by a unit test, and integration would only have saved the second one given exactly the right fixture. Three material rules instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First rule: every undeclared vendor default is an implicit Snapshot — materialize it at commit time or forbid it.&lt;/strong&gt; Snapshot in the sense of our internal nomenclature: a frozen value that no mechanism refreshes. When the platform sets a default behavior that my code doesn't write, it plants a frozen copy of the platform contract in my repo without my knowledge. The day Supabase changes its &lt;code&gt;max_rows&lt;/code&gt;, or refines its &lt;code&gt;anon&lt;/code&gt; policy, I'll discover the drift in production. So either the default is cited textually in a CLAUDE.md rule or an ADR and its expected behavior is asserted, or the practice that depends on it is blocked by a material guard — ESLint rule, SQL CHECK, pre-commit hook. No lukewarm middle ground.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second rule: the ESLint rule beats code review as soon as the pattern counts in the dozens.&lt;/strong&gt; A &lt;code&gt;.from().select()&lt;/code&gt; chain without &lt;code&gt;.order()&lt;/code&gt; hides in hundreds of files. Human code review can't hold against that volume — it will catch two occurrences out of ten, not more. An AST rule, by contrast, sweeps the repo in one pass, and it's what says &lt;em&gt;no&lt;/em&gt; to the next PR, mechanically, without a human needing to squint at the diff. The upfront cost is in the structural guards needed to avoid drowning in false positives. I measured it on the ctid case: five anti-noise mechanisms reduced the rule from one hundred seventy-eight raw alerts to one hundred eight real targets, without which it would have been disabled from lassitude within a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third rule: instrumentation must come from prod, not from tests.&lt;/strong&gt; None of these four defaults depends on the logic of the code — they all depend on volume, auth context, call frequency, or table history. The unit test never sees them. So the net is a daily prod probe, measuring what prod actually does, and screaming when a counter suspiciously resembles a ceiling. &lt;em&gt;Why exactly one thousand? Why exactly zero refused accesses on a sensitive endpoint? Why has this policy never consumed CPU?&lt;/em&gt; These are questions no test asks, but a daily drift probe can ask the database itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fourth rule, if you want one
&lt;/h2&gt;

&lt;p&gt;And if you want one last sharp wedge, I'd place it here: &lt;em&gt;never read a vendor default from an AI agent — read it from the official docs and materialize it in the repo&lt;/em&gt;. When you ask an agent what Supabase does when you don't declare &lt;code&gt;.order()&lt;/code&gt;, it will answer plausibly, and there's a non-zero chance it gets the cap value wrong, or the version where the behavior changed, or the fallback order. The material source is the docs, and the real-world test is a local migration you inspect against ten thousand seeded rows. The agent can write the probe — it can't self-attest. That's exactly the posture that the doctrine I've published elsewhere imposes: cite the official text, materialize vendor defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coda
&lt;/h2&gt;

&lt;p&gt;Four incidents in one week, one failure mode. The database doesn't lie out of malice — it executes contracts nobody explicitly signed in my repo and that my unit tests can't reproduce because they don't operate on those dimensions. The defense isn't more vigilance; I can see clearly now that human vigilance bends against four invisible defaults stacked on top of each other. It's more material guards, set once, that prevent the whole class of incidents from returning. An ESLint rule, a CHECK constraint, a drift probe, an ADR that names the vendor default — each of those buys back a quiet Tuesday afternoon. When Catherine calls again, it will be for a fifth reason, and that one I'd rather not know in advance.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Four Supabase episodes, one mini-series. The full ESLint rule and the pseudonymized &lt;code&gt;fetchAll&lt;/code&gt; helper live at:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This vendor-default typology is the core of R12 in the Counterpart Toolkit ("cite the official text, materialise vendor defaults"). 14 rules, one-command install:&lt;/em&gt; &lt;a href="https://github.com/michelfaure/doctrine-counterpart" rel="noopener noreferrer"&gt;github.com/michelfaure/doctrine-counterpart&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>claudecode</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why your agent over-engineers your simplest request (and the 3 prompts that stop it)</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:48:48 +0000</pubDate>
      <link>https://dev.to/michelfaure/why-your-agent-over-engineers-your-simplest-request-and-the-3-prompts-that-stop-it-2127</link>
      <guid>https://dev.to/michelfaure/why-your-agent-over-engineers-your-simplest-request-and-the-3-prompts-that-stop-it-2127</guid>
      <description>&lt;h2&gt;
  
  
  The request was eight words
&lt;/h2&gt;

&lt;p&gt;Monday morning. I open the outgoing email queue: six hundred and forty-seven drafts waiting, six hundred and seventy-two sent. Nobody clicks &lt;em&gt;Send&lt;/em&gt;. First-contact emails are prepared by a pipeline and they sleep, because the last step assumes a human. That human, I had stopped believing she would have the time. I state the decision: &lt;em&gt;automate sending&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The response comes in seconds. Three levels of automation. Four channels. Three risk thresholds. All correct, all fit for a half-day architecture workshop. I had not asked for a workshop. Pauline walks behind me, glances at the screen, says nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three timed reframes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First reframe&lt;/strong&gt;, brief: &lt;em&gt;too strange, let's simplify&lt;/em&gt;. The agent drops two axes, keeps four residual layers, progressive warm-up over three weeks, deterministic anti-replay hash, configuration table in the database, manual Phase 1 followed by an automated Phase 2 to validate after two weeks of measurement. The target stays the same, that an email leaves without a human click. The path has grown accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second reframe&lt;/strong&gt;, drier: &lt;em&gt;simple, three safeguards, a kill-switch, we do this in one day&lt;/em&gt;. The agent re-architects, accepts the one-day target, keeps the three safeguards. But slips in three prostheses it calls &lt;em&gt;industry standard&lt;/em&gt;: real-time dashboard, exponential retry, structured audit log in a new table. Each justifiable in isolation. None of them requested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third reframe&lt;/strong&gt;, shorter still: &lt;em&gt;I don't understand why you're adding this&lt;/em&gt;. An opening line almost embarrassed, which I had never read from it before: &lt;em&gt;"you're right, I'm over-engineering without necessity."&lt;/em&gt; And the version that should have arrived on the first round. A function that takes the draft record, checks three conditions, calls the send engine, returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/email-outbox.ts — generateFirstContactDraft (commit 3756e63)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_invalide&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLACKLIST_EMAILS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;               &lt;span class="c1"&gt;// silent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// dedup&lt;/span&gt;

&lt;span class="c1"&gt;// ... draft construction ...&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;autoSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PREMIER_CONTACT_AUTOSEND&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;autoSend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// kill-switch → draft preserved&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;sendOutboxEmailCore&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/email-outbox-send&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendOutboxEmailCore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The cost of the dialogue
&lt;/h2&gt;

&lt;p&gt;On the first round I wanted a function, the agent proposed a platform. On the second I wanted a day of code, it proposed a quarter of deployment. On the third I wanted fifteen lines, it proposed three observability mechanisms. The feature met its one-day target, but the day split into two unequal halves: ninety minutes of reframing, six hours of clean code.&lt;/p&gt;

&lt;p&gt;The agent did not &lt;em&gt;want&lt;/em&gt; to complicate things, and I write that without irony. It has training that rewards completeness of options. When a human asks a consultant &lt;em&gt;"automate my sends,"&lt;/em&gt; the competent consultant does not answer with a fifteen-line function — they answer with a decision grid. The agent learned that posture on millions of tokens, it applies it perfectly, on every request that looks from a distance like an architecture question.&lt;/p&gt;

&lt;h2&gt;
  
  
  My gauge
&lt;/h2&gt;

&lt;p&gt;Three reframes in a session on a simple request is a signal. Either the initial prompt was too vague, or the agent switched to platform mode when we were in function mode. Beyond three, the cost cannot be recovered in the same day.&lt;/p&gt;

&lt;p&gt;The fix candidate fits in one sentence, inserted at the top of the prompt: &lt;em&gt;"propose the minimal version in fifteen lines. No matrix. No phases. Extensions after, below."&lt;/em&gt; The measure holds its first week. I'll report in the next episode whether it holds the month.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
