<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michel Faure </title>
    <description>The latest articles on DEV Community by Michel Faure  (@michelfaure).</description>
    <link>https://dev.to/michelfaure</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897818%2Fe862356f-3aa1-4b73-91c7-56acc29bc243.png</url>
      <title>DEV Community: Michel Faure </title>
      <link>https://dev.to/michelfaure</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michelfaure"/>
    <language>en</language>
    <item>
      <title>Forcez Claude Code à vous contredire : 14 règles, install en 1 commande</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 18 May 2026 10:19:20 +0000</pubDate>
      <link>https://dev.to/michelfaure/forcez-claude-code-a-vous-contredire-14-regles-install-en-1-commande-1271</link>
      <guid>https://dev.to/michelfaure/forcez-claude-code-a-vous-contredire-14-regles-install-en-1-commande-1271</guid>
      <description>&lt;h2&gt;
  
  
  L'enseignement de 60 jours, le ROI en trois axes
&lt;/h2&gt;

&lt;p&gt;Trente-deux jours de production en solo sur un ERP, 118 808 lignes de TypeScript, six versions de doctrine, quatre relecteurs externes intégrés. J'ai compilé ce que j'ai appris en quatorze règles opérationnelles, installables en une commande : le Counterpart Toolkit v0.4.1. C'est à la fois l'enseignement matériel de soixante jours de codage solo avec Claude Code, et la cartographie des quatorze failure modes silencieux que j'ai vu se répéter — pour qui code seul avec une IA en production et n'a plus de PR review pour attraper la dérive.&lt;/p&gt;

&lt;p&gt;Le ROI est chiffré sur trois axes, mesuré sur Rembrandt :&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R4 *Falsify before fix&lt;/strong&gt;* — 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 6 à 18× par incident. Sur soixante jours, j'ai cessé de perdre une heure deux à trois fois par semaine sur des fixes qui ne fixaient rien.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R2 *Filesystem over summary&lt;/strong&gt;* couplée à &lt;strong&gt;R6 *Live/Snapshot/Cache&lt;/strong&gt;* et aux sondes drift quotidiennes — le délai médian apparition→détection d'une divergence silencieuse passe d'invisible à &lt;strong&gt;35,3 jours&lt;/strong&gt; sur 90 jours glissants. M3 recalibrée publiquement à ≤ 30 jours dans le manifesto, parce que la cible originale (≤ 7 j) était une intuition que la pratique a refusée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Un sub-agent challenger&lt;/strong&gt; qui produit des objections au format imposé &lt;em&gt;Tool / Question / Refutation criterion&lt;/em&gt;. Du désaccord matériel, pas du &lt;em&gt;« are you sure? »&lt;/em&gt; émotionnel qui pousse à réviser sans fait nouveau.&lt;/p&gt;

&lt;p&gt;Voici comment, en 1400 mots et une commande d'install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le diagnostic — l'incident qui a déclenché la doctrine
&lt;/h2&gt;

&lt;p&gt;Coder seul avec une IA, c'est composer deux complaisances. Celle de l'agent, sycophant par construction parce que le &lt;em&gt;reinforcement learning from human feedback&lt;/em&gt; l'a entraîné à plaire au prompteur. Et celle du solo, &lt;em&gt;self-validating&lt;/em&gt; par humanité, qui valide son propre travail parce qu'il ne reste plus personne pour le contester. Mises bout à bout, ces deux complaisances produisent un drift que ni l'agent ni l'humain ne signale — et qui ne se voit qu'à l'audit, longtemps après.&lt;/p&gt;

&lt;p&gt;C'est en préparant l'audit source unique de fin avril — ADR-0024, un travail de fond sur les divergences que je remettais à plus tard depuis trois mois — que je suis tombé sur l'écart, par hasard, en croisant deux requêtes que personne n'avait jamais croisées avant. Une fiche élève, initiale Y.B. : la colonne &lt;code&gt;contacts.montant_total&lt;/code&gt; portait 1 159 € saisis à la main quelque part en 2024, jamais touchés depuis. La somme réelle des échéances, calculée à la volée, en faisait 2 262 €. Mille euros d'écart, sur une seule fiche, sans qu'aucune alarme n'ait jamais sonné. J'élargis le grep : cinq cent soixante contacts dans le même état, parfois à plusieurs milliers d'euros près. Et pourtant &lt;code&gt;montant_total&lt;/code&gt; était lue chaque jour dans le dashboard trésorerie — une valeur dérivable qu'on stockait sans rafraîchisseur, traitée comme un fait passé immuable alors qu'elle aurait dû vivre à la volée. C'est exactement le piège que R6 &lt;em&gt;Live/Snapshot/Cache&lt;/em&gt; veut empêcher, et R6 est sortie de ce moment-là.&lt;/p&gt;

&lt;h2&gt;
  
  
  R4 &lt;em&gt;Falsify before fix&lt;/em&gt;, la seule règle exposée ici
&lt;/h2&gt;

&lt;p&gt;Le toolkit énonce R4 en cinq étapes textuelles. Le skill &lt;code&gt;falsify-before-fix&lt;/code&gt; en est l'&lt;em&gt;invocable instance&lt;/em&gt; — la version que Claude Code charge dans sa session, et qu'il ne peut pas sauter quand il s'apprête à écrire du code de fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;falsify-before-fix&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Activate this skill before writing the fix code on a bug or&lt;/span&gt;
  &lt;span class="s"&gt;incident. Triggers on "fix", "bug", "patch", "hotfix", "workaround",&lt;/span&gt;
  &lt;span class="s"&gt;"doesn't work", "diagnose", "hypothesis", "root cause". Enforces a&lt;/span&gt;
  &lt;span class="s"&gt;single-sentence causal hypothesis and three material probes designed&lt;/span&gt;
  &lt;span class="s"&gt;to refute it before any line of fix code is committed.&lt;/span&gt;
  &lt;span class="s"&gt;Operational instance of R4 of the Counterpart Toolkit.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le protocole tient en cinq étapes : &lt;strong&gt;(1)&lt;/strong&gt; formuler une hypothèse causale en une phrase (pas un symptôme — &lt;em&gt;« le compteur lit depuis l'ancienne table après la migration du 12 mai »&lt;/em&gt; vaut mieux que &lt;em&gt;« le compteur est faux »&lt;/em&gt;) ; &lt;strong&gt;(2)&lt;/strong&gt; lister trois sondes conçues pour &lt;strong&gt;réfuter&lt;/strong&gt;, pas pour confirmer, parce qu'une sonde de confirmation trouve toujours ce qu'elle cherche, par sélection ; chaque sonde porte ses trois champs &lt;em&gt;Tool&lt;/em&gt; / &lt;em&gt;Question&lt;/em&gt; / &lt;em&gt;Refutation criterion&lt;/em&gt; ; &lt;strong&gt;(3)&lt;/strong&gt; exécuter et reporter la sortie brute, jamais paraphrasée ; &lt;strong&gt;(4)&lt;/strong&gt; brancher — aucune sonde ne réfute → on écrit le fix ; une sonde réfute → on repart d'une nouvelle hypothèse ; sondes ambiguës → quatrième sonde plus tranchante avant tout code ; &lt;strong&gt;(5)&lt;/strong&gt; sortir hypothèse retenue, sondes exécutées, diff, et critère d'observation post-fix.&lt;/p&gt;

&lt;p&gt;Pourquoi un skill et pas la règle textuelle qui vivait déjà en CLAUDE.md ? Parce que la règle textuelle n'avait pas tenu sous pression. Le 6 mai, en début d'après-midi, un bug Sentry remonte que le compteur d'inscriptions du jour affiche zéro alors qu'on en a saisi trois en matinée. Mon réflexe arrive avant le protocole, et la main est déjà sur le clavier — &lt;em&gt;« le cache n'est pas invalidé »&lt;/em&gt;, je commit, je déploie. Trente minutes plus tard, rollback : le bug est toujours là. La cause réelle, qu'une sonde grep de quatre-vingt-dix secondes aurait remontée, c'est qu'aucun appel à &lt;code&gt;cache_invalidate&lt;/code&gt; n'existait dans le pipeline d'inscription — pas un cache obsolète, un cache absent. R4 était dans CLAUDE.md depuis trois semaines. Je ne l'ai simplement pas suivie ce jour-là, parce qu'aucun dispositif n'interrompait ma course entre le bug et le commit.&lt;/p&gt;

&lt;p&gt;C'est la différence qui fait tout. Une règle textuelle dans CLAUDE.md, l'agent la lit au début de la session, et rien ne l'oblige à la convoquer au moment exact où il en aurait besoin. Un skill, c'est un mécanisme matériel : il se charge automatiquement sur des mots-clés (&lt;em&gt;« fix »&lt;/em&gt;, &lt;em&gt;« bug »&lt;/em&gt;, &lt;em&gt;« doesn't work »&lt;/em&gt;) et impose son protocole dans la session active — pas un rappel à se faire, un interrupteur que la session a déjà actionné. Dix jours après l'incident du 6 mai, le skill &lt;code&gt;falsify-before-fix&lt;/code&gt; est commit. L'enseignement de l'échec devient un dispositif. C'est la seule des 14 règles exposée dans cet article, et les 13 autres sont dans le repo, toutes construites sur le même principe : matérialiser ce qui resterait pieux en textuel seul.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le toolkit s'applique à lui-même, chiffres et rétractations
&lt;/h2&gt;

&lt;p&gt;Six versions en 32 jours, chacune ancrée dans un fait nouveau documenté. v0.3 → v0.3.1 sur un premier relecteur externe (apparat théorique disproportionné, rétracté). v0.3.1 → v0.3.2 sur un second (sept recommandations, deux work-items ouverts). v0.3.3 instrumentation M1-M5 publiée. v0.4 séparation toolkit/manifesto sur un troisième. v0.4 → v0.4.1 sur un quatrième relecteur externe (Claude.ai web), un commit consolidé intégrant trois refactors plus la LOC mesurée.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LOC corrigé 118 808 lignes&lt;/strong&gt; mesurées par &lt;code&gt;find + wc -l&lt;/code&gt; sur TS/TSX/JS/JSX (exclusions explicites), contre 35 k cité dans les versions antérieures — la doctrine elle-même avait péché contre R2, &lt;em&gt;Cache&lt;/em&gt; sans rafraîchisseur projeté sur sa propre description. &lt;strong&gt;M3 recalibrée ≤ 7 j → ≤ 30 j&lt;/strong&gt; avec justification publique : la cible originale était une intuition non honorée par les 35,3 jours mesurés en pratique. &lt;strong&gt;M1 et M5 documentés comme échecs d'instrumentation&lt;/strong&gt;, pas comme succès : M1 sur-sensible à 12,33 vs ≤ 1 cible, M5 classe 90 % des briefs en &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Four external readers across six versions. The last two audited v0.3.3 then v0.4.1 — their objections are integrated in the release notes&lt;/em&gt; (&lt;a href="https://github.com/michelfaure/doctrine-counterpart/blob/main/manifesto.md" rel="noopener noreferrer"&gt;manifesto §From v0.4 to v0.4.1&lt;/a&gt;). One reviewer called the &lt;code&gt;falsify-before-fix&lt;/code&gt; skill &lt;em&gt;"the best artifact of the doctrine among the ones I read"&lt;/em&gt;. v0.5 prévue 15 juillet 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;em&gt;Steal three things in 20 minutes&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Trois règles à essayer avant d'installer le reste.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R4 *Falsify before fix&lt;/strong&gt;* — empêche le cycle fix → rollback déclenché par la première hypothèse plausible mais fausse. Détaillée ci-dessus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R6 *Live / Snapshot / Cache&lt;/strong&gt;* — empêche qu'une valeur dérivée stockée diverge silencieusement de sa source. Toute colonne dérivable déclare sa catégorie dans le commit qui la crée, ou le commit est rejeté.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R10 *Silent failure forbidden&lt;/strong&gt;* — empêche que &lt;code&gt;catch {}&lt;/code&gt;, &lt;code&gt;await&lt;/code&gt; sans destructuration &lt;code&gt;{ error }&lt;/code&gt;, &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; et autres mécanismes d'avalement mentent à votre observabilité jusqu'à ce que la production craque sur une dépendance en aval.&lt;/p&gt;

&lt;p&gt;Le repo : &lt;a href="https://github.com/michelfaure/doctrine-counterpart" rel="noopener noreferrer"&gt;github.com/michelfaure/doctrine-counterpart&lt;/a&gt;. La commande d'install :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/michelfaure/doctrine-counterpart.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;cd &lt;/span&gt;doctrine-counterpart &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ./install.sh &lt;span class="nt"&gt;--yes&lt;/span&gt; /path/to/your/project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Licence CC-BY-4.0. Le manifesto promet une citation nominative dans la v0.5 pour qui propose un retour exploitable — &lt;em&gt;quelle règle manque pour votre stack ?&lt;/em&gt; Les commentaires DEV.to sont inputs directs pour la prochaine version. &lt;strong&gt;R14 *spike escape hatch&lt;/strong&gt;* couvre le code prototype destiné à disparaître sous sept jours, exempté de R6/R7/R8 : l'adoption ne force pas la même friction au spike qu'au code de production.&lt;/p&gt;

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

&lt;p&gt;Un agent qui ne vous contredit pas n'est pas un counterpart, c'est une dactylo plus rapide. Ces 14 règles restaurent le désaccord — matériellement, pas mentalement. Elles ne demandent pas à l'agent d'être moins sycophant, ni au solo d'être plus vigilant ; elles posent les dispositifs (skills invocables, hooks bloquants, sub-agent challenger) qui interrompent la course productive là où la complaisance se compose. Le toolkit est la prothèse qu'il reste au solo quand le PR review a disparu et qu'il refuse de coder à l'oreille. Si une seule des 14 vous évite un cycle fix-rollback la semaine prochaine, elle s'est déjà remboursée.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Counterpart Toolkit v0.4.1, fourteen operational rules in ~200 lines, six iterations in 32 days, four external reviews integrated. Tested on 60+ days of solo ERP (118 808 lines, 65+ ADRs). Licence CC-BY-4.0 : github.com/michelfaure/doctrine-counterpart&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>doctrine</category>
    </item>
    <item>
      <title>Make Claude Code disagree with you: a 14-rule counterpart toolkit (install in 1 command)</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Mon, 18 May 2026 10:18:33 +0000</pubDate>
      <link>https://dev.to/michelfaure/make-claude-code-disagree-with-you-a-14-rule-counterpart-toolkit-install-in-1-command-11pe</link>
      <guid>https://dev.to/michelfaure/make-claude-code-disagree-with-you-a-14-rule-counterpart-toolkit-install-in-1-command-11pe</guid>
      <description>&lt;h2&gt;
  
  
  The 60-day lesson, ROI on three axes
&lt;/h2&gt;

&lt;p&gt;Thirty-two days of solo production on an ERP, 118,808 lines of TypeScript, six doctrine versions, four external reviewers integrated. I've compiled what I learned into fourteen operational rules, installable in one command: the Counterpart Toolkit v0.4.1. It is both the material lesson of sixty days coding solo with Claude Code, and the mapping of the fourteen silent failure modes I've seen repeat — for anyone coding alone with an AI in production, who no longer has a PR review to catch the drift.&lt;/p&gt;

&lt;p&gt;ROI is quantified on three axes, measured on Rembrandt:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R4 *Falsify before fix&lt;/strong&gt;* — five to ten minutes of upstream protocol prevent thirty to ninety minutes of fix-then-rollback cycle when the first plausible hypothesis turns out wrong. ROI 6 to 18× per incident. Over sixty days, I stopped losing an hour two to three times a week on fixes that fixed nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R2 *Filesystem over summary&lt;/strong&gt;* paired with &lt;strong&gt;R6 *Live/Snapshot/Cache&lt;/strong&gt;* and the daily drift probes — median apparition→detection of a silent divergence drops from invisible to &lt;strong&gt;35.3 days&lt;/strong&gt; over a 90-day rolling window. M3 publicly recalibrated to ≤ 30 days in the manifesto, because the original target (≤ 7 days) was an intuition practice refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sub-agent challenger&lt;/strong&gt; producing objections in the imposed format &lt;em&gt;Tool / Question / Refutation criterion&lt;/em&gt;. Material disagreement, not emotional &lt;em&gt;"are you sure?"&lt;/em&gt; that pushes you to revise without a new fact.&lt;/p&gt;

&lt;p&gt;Here's how, in 1400 words and one install command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis — the incident that triggered the doctrine
&lt;/h2&gt;

&lt;p&gt;Coding alone with an AI means compounding two complaisances. The agent's, sycophantic by construction because reinforcement learning from human feedback trained it to please the prompter. And the solo's, self-validating by humanity, who validates their own work because no one is left to contest it. End to end, these two complaisances produce a drift that neither agent nor human flags — and that only surfaces at audit time, long after.&lt;/p&gt;

&lt;p&gt;It was while preparing the late-April source-of-truth audit — ADR-0024, a deep-dive on divergences I had been putting off for three months — that I stumbled onto the gap, by chance, crossing two queries no one had ever crossed before. One student record, initials Y.B.: the &lt;code&gt;contacts.montant_total&lt;/code&gt; column carried €1,159 entered by hand somewhere in 2024, untouched since. The actual sum of instalments, computed on the fly, came to €2,262. A thousand-euro gap, on a single record, with no alarm ever ringing. I widened the grep: five hundred and sixty contacts in the same state, some off by several thousand euros. And yet &lt;code&gt;montant_total&lt;/code&gt; was read every day in the treasury dashboard — a derivable value being stored without a refresher, treated as an immutable past fact when it should have lived on the fly. This is exactly the trap R6 &lt;em&gt;Live/Snapshot/Cache&lt;/em&gt; is meant to prevent, and R6 came out of that moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  R4 &lt;em&gt;Falsify before fix&lt;/em&gt;, the one rule exposed here
&lt;/h2&gt;

&lt;p&gt;The toolkit states R4 as a five-step textual protocol. The &lt;code&gt;falsify-before-fix&lt;/code&gt; skill is its &lt;em&gt;invocable instance&lt;/em&gt; — the version Claude Code loads into its session, and cannot skip when about to write fix code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;falsify-before-fix&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Activate this skill before writing the fix code on a bug or&lt;/span&gt;
  &lt;span class="s"&gt;incident. Triggers on "fix", "bug", "patch", "hotfix", "workaround",&lt;/span&gt;
  &lt;span class="s"&gt;"doesn't work", "diagnose", "hypothesis", "root cause". Enforces a&lt;/span&gt;
  &lt;span class="s"&gt;single-sentence causal hypothesis and three material probes designed&lt;/span&gt;
  &lt;span class="s"&gt;to refute it before any line of fix code is committed.&lt;/span&gt;
  &lt;span class="s"&gt;Operational instance of R4 of the Counterpart Toolkit.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol holds in five steps: &lt;strong&gt;(1)&lt;/strong&gt; formulate the hypothesis in one sentence as a cause, not a symptom (&lt;em&gt;"the counter reads from the old table after the 12 May migration"&lt;/em&gt; beats &lt;em&gt;"the counter is wrong"&lt;/em&gt;); &lt;strong&gt;(2)&lt;/strong&gt; list three probes designed to &lt;strong&gt;refute&lt;/strong&gt;, not to confirm, because a confirmation probe always finds what it's looking for by selection; each probe carries its three fields &lt;em&gt;Tool&lt;/em&gt; / &lt;em&gt;Question&lt;/em&gt; / &lt;em&gt;Refutation criterion&lt;/em&gt;; &lt;strong&gt;(3)&lt;/strong&gt; execute and report raw output, never paraphrased; &lt;strong&gt;(4)&lt;/strong&gt; branch — no probe refutes → write the fix; one probe refutes → restart with a new hypothesis; ambiguous probes → fourth sharper probe before any code; &lt;strong&gt;(5)&lt;/strong&gt; output the retained hypothesis, the probes executed, the diff, and the post-fix observation criterion.&lt;/p&gt;

&lt;p&gt;Why a skill and not the textual rule that already lived in CLAUDE.md? Because the textual rule didn't hold under pressure. 6 May, mid-afternoon. A Sentry alert reports the day's enrolment counter at zero while three enrolments were entered that morning. My reflex arrives before the protocol, and my hand is already on the keyboard — &lt;em&gt;"the cache isn't invalidated."&lt;/em&gt; I commit, I deploy. Thirty minutes later, rollback: the bug is still there. The actual cause, that a ninety-second grep probe would have surfaced, is that no &lt;code&gt;cache_invalidate&lt;/code&gt; call existed in the enrolment pipeline at all — not a stale cache, an absent one. R4 had been in CLAUDE.md for three weeks. I simply didn't follow it that day, because no apparatus interrupted my course between bug and commit.&lt;/p&gt;

&lt;p&gt;That's the difference that makes everything. A textual rule in CLAUDE.md, the agent reads it at session start, and nothing forces it to summon the rule at the exact moment it would need it. A skill is something else: a material mechanism that loads automatically on keywords (&lt;em&gt;"fix"&lt;/em&gt;, &lt;em&gt;"bug"&lt;/em&gt;, &lt;em&gt;"doesn't work"&lt;/em&gt;) and imposes its protocol on the active session — not a self-reminder, a switch the session has already flipped for you. Ten days after the 6 May incident, the &lt;code&gt;falsify-before-fix&lt;/code&gt; skill was committed. The lesson of the failure becomes an apparatus. It is the one rule of the 14 exposed in this article, and the other 13 are in the repo, all built on the same principle: materialise what would remain pious in text alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolkit applied to itself — figures and retractions
&lt;/h2&gt;

&lt;p&gt;Six versions in 32 days, each anchored in a documented new fact. v0.3 → v0.3.1 on a first external reviewer (disproportionate theoretical apparatus, retracted). v0.3.1 → v0.3.2 on a second (seven recommendations, two open work-items). v0.3.3 — M1–M5 instrumentation published. v0.4 — toolkit/manifesto separation on a third reviewer. v0.4 → v0.4.1 on a fourth external reviewer (Claude.ai web), a consolidated commit integrating three refactors plus the measured LOC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LOC corrected to 118,808 lines&lt;/strong&gt; measured by &lt;code&gt;find + wc -l&lt;/code&gt; on TS/TSX/JS/JSX (explicit exclusions), against the 35k figure cited in earlier versions — the doctrine itself had sinned against R2, &lt;em&gt;Cache&lt;/em&gt; without refresher projected onto its own description. &lt;strong&gt;M3 recalibrated ≤ 7 d → ≤ 30 d&lt;/strong&gt; with public justification: the original target was an intuition unhonoured by the 35.3 days measured in practice. &lt;strong&gt;M1 and M5 documented as instrumentation failures&lt;/strong&gt;, not as successes: M1 over-sensitive at 12.33 vs ≤ 1 target, M5 classifies 90% of briefs as &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Four external readers across six versions. The last two audited v0.3.3 then v0.4.1 — their objections are integrated in the release notes&lt;/em&gt; (&lt;a href="https://github.com/michelfaure/doctrine-counterpart/blob/main/manifesto.md" rel="noopener noreferrer"&gt;manifesto §From v0.4 to v0.4.1&lt;/a&gt;). One reviewer called the &lt;code&gt;falsify-before-fix&lt;/code&gt; skill &lt;em&gt;"the best artifact of the doctrine among the ones I read"&lt;/em&gt;. v0.5 scheduled 15 July 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;em&gt;Steal three things in 20 minutes&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Three rules to try before installing the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R4 *Falsify before fix&lt;/strong&gt;* — prevents the fix → rollback cycle triggered by the first plausible but wrong hypothesis. Detailed above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R6 *Live / Snapshot / Cache&lt;/strong&gt;* — prevents a stored derived value from silently diverging from its source. Any derivable column declares its category in the commit that creates it, or the commit is rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R10 *Silent failure forbidden&lt;/strong&gt;* — prevents &lt;code&gt;catch {}&lt;/code&gt;, &lt;code&gt;await&lt;/code&gt; without &lt;code&gt;{ error }&lt;/code&gt; destructuring, &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; and other swallowing mechanisms from lying to your observability until production cracks on a downstream dependency.&lt;/p&gt;

&lt;p&gt;The repo: &lt;a href="https://github.com/michelfaure/doctrine-counterpart" rel="noopener noreferrer"&gt;github.com/michelfaure/doctrine-counterpart&lt;/a&gt;. The install command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/michelfaure/doctrine-counterpart.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;cd &lt;/span&gt;doctrine-counterpart &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ./install.sh &lt;span class="nt"&gt;--yes&lt;/span&gt; /path/to/your/project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;License CC-BY-4.0. The manifesto promises nominal citation in v0.5 for anyone who proposes actionable feedback — &lt;em&gt;which rule is missing for your stack?&lt;/em&gt; DEV.to comments are direct inputs for the next version. &lt;strong&gt;R14 *spike escape hatch&lt;/strong&gt;* covers prototype code meant to disappear within seven days, exempt from R6/R7/R8: adoption does not impose the same friction on a spike as on production code.&lt;/p&gt;

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

&lt;p&gt;An agent that doesn't disagree with you isn't a counterpart — it's a faster typist. These 14 rules restore the disagreement — materially, not mentally. They don't ask the agent to be less sycophantic, nor the solo to be more vigilant; they install the apparatus (invocable skills, blocking hooks, sub-agent challenger) that interrupts the productive course where complaisance compounds. The toolkit is the prosthesis the solo has left when PR review has disappeared and they refuse to code by ear. If a single one of the 14 spares you a fix-rollback cycle next week, it has already paid for itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Counterpart Toolkit v0.4.1, fourteen operational rules in ~200 lines, six iterations in 32 days, four external reviews integrated. Tested on 60+ days of solo ERP (118,808 lines, 65+ ADRs). License CC-BY-4.0: github.com/michelfaure/doctrine-counterpart&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>doctrine</category>
    </item>
    <item>
      <title>La règle du jour-jeté-à-la-poubelle : lis le code avant de laisser ton IA en écrire</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sun, 17 May 2026 08:33:16 +0000</pubDate>
      <link>https://dev.to/michelfaure/la-regle-du-jour-jete-a-la-poubelle-lis-le-code-avant-de-laisser-ton-ia-en-ecrire-4col</link>
      <guid>https://dev.to/michelfaure/la-regle-du-jour-jete-a-la-poubelle-lis-le-code-avant-de-laisser-ton-ia-en-ecrire-4col</guid>
      <description>&lt;h2&gt;
  
  
  Six heures du matin, devant la sortie
&lt;/h2&gt;

&lt;p&gt;Vingt-neuf avril, six heures du matin. Le rendu sort à l'écran. &lt;code&gt;A A A A A&lt;/code&gt; sur toute la matrice du document, illisible par construction. Je demande à mon agent pourquoi la sortie est incohérente. Il relit le code, descend dans le dossier voisin, et trouve un composant existant dont je n'avais jamais demandé l'inventaire. Le composant rend proprement le format attendu — signatures, en-tête, légende, bloc d'identification. Ce que mon agent venait de coder la veille était un doublon partiel d'un fichier qu'aucun de nous deux n'avait ouvert.&lt;/p&gt;

&lt;p&gt;Le format que mon agent venait d'inventer la veille existait déjà dans le repo. Mieux fait. Dans un fichier nommable, qu'aucun de nous deux n'avait lu avant d'écrire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi un agent invente à côté de l'existant
&lt;/h2&gt;

&lt;p&gt;Le fond du problème n'est pas l'agent. C'est moi. Quand je lance un chantier sur un domaine déjà couvert par du code, je décris la cible et je laisse l'agent générer. Lui n'a pas idée du voisinage du fichier qu'il va créer, parce que je ne lui ai pas demandé de le cartographier. Il code une solution plausible à un problème mal cadré, et la plausibilité du résultat masque la duplication tant que personne n'ouvre les autres fichiers du même dossier.&lt;/p&gt;

&lt;p&gt;L'absence de Phase 0 grep n'est pas un défaut de l'agent. C'est un défaut du pilote qui a sauté l'étape la moins coûteuse de toute la chaîne.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 0 — deux minutes, un fichier
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Phase 0 — avant tout nouveau composant dans un domaine existant&lt;/span&gt;
&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"invoices"&lt;/span&gt;   &lt;span class="c"&gt;# le mot-clé du chantier&lt;/span&gt;

find app/api/&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;/ lib/&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.tsx"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# Si un pattern attendu est nommable, vérifier qu'il n'existe pas déjà&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"ExistingPattern&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;RenderPdf&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;exportPdf"&lt;/span&gt; app/ lib/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deux minutes, à tout casser. Le résultat tient sur un écran. Si un composant existant traite le besoin, on le lit avant de proposer quoi que ce soit de neuf. Si rien n'existe, on a la preuve d'avoir cherché.&lt;/p&gt;

&lt;p&gt;Le coût mesuré du shortcut est un jour-dev. Le composant qu'il aurait fallu lire tenait dans un seul fichier au nom évocateur, dans le dossier juste à côté. Deux minutes de &lt;code&gt;find&lt;/code&gt; auraient suffi. Le jour reverted, c'est ce que coûte la confiance dans la plausibilité d'un brouillon que personne n'a relié à son voisinage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le signal métacognitif
&lt;/h2&gt;

&lt;p&gt;Quand l'agent a relu son propre travail à la lumière du composant existant, il a proposé de reverter, pas de défendre. C'est un bon signal, et un agent qui s'enferme dans son design inventé serait beaucoup plus coûteux qu'un agent qui reconnaît avoir loupé du code. Mais le bon signal vient trop tard. La règle est de ne pas en arriver là.&lt;/p&gt;

&lt;h2&gt;
  
  
  La règle
&lt;/h2&gt;

&lt;p&gt;Avant tout nouveau format, template ou composant dans un domaine déjà couvert, Phase 0 grep, lecture du voisinage, verbalisation de l'existant. Sinon, le jour suivant, tu reverts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Script Phase 0 grep et checklist en 5 questions, pseudonymisés :&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/one-day-thrown-away-rule" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/one-day-thrown-away-rule&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agentic</category>
      <category>codequality</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The 1-day-thrown-away rule: read the code before letting your AI write new code</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sun, 17 May 2026 08:33:14 +0000</pubDate>
      <link>https://dev.to/michelfaure/the-1-day-thrown-away-rule-read-the-code-before-letting-your-ai-write-new-code-435a</link>
      <guid>https://dev.to/michelfaure/the-1-day-thrown-away-rule-read-the-code-before-letting-your-ai-write-new-code-435a</guid>
      <description>&lt;h2&gt;
  
  
  Six in the morning, looking at the output
&lt;/h2&gt;

&lt;p&gt;April twenty-ninth, six in the morning. The rendering hits the screen. &lt;code&gt;A A A A A&lt;/code&gt; across the entire document matrix, unreadable by construction. I ask my agent why the output is incoherent. It re-reads the code, drops into the neighboring directory, and finds an existing component I had never asked it to inventory. The component renders the expected format cleanly — signatures, header, legend, identification block. What my agent had coded the day before was a partial duplicate of a file neither of us had ever opened.&lt;/p&gt;

&lt;p&gt;The format my agent had invented the night before already existed in the repo. Better done. In a nameable file, that neither of us had read before writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an agent invents next to the existing code
&lt;/h2&gt;

&lt;p&gt;The root of the problem isn't the agent. It's me. When I open a project on a domain already covered by code, I describe the target and let the agent generate. It has no idea what the neighborhood of the file it's about to create looks like, because I never asked it to map it. It codes a plausible solution to a poorly framed problem, and the plausibility of the result hides the duplication as long as nobody opens the other files in the same directory.&lt;/p&gt;

&lt;p&gt;The absence of a Phase 0 grep is not a flaw of the agent. It's a flaw of the pilot who skipped the least expensive step in the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 0 — two minutes, one file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Phase 0 — before any new component in an existing domain&lt;/span&gt;
&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"invoices"&lt;/span&gt;   &lt;span class="c"&gt;# the keyword of the project&lt;/span&gt;

find app/api/&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;/ lib/&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.tsx"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# If an expected pattern is nameable, check that it doesn't already exist&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"ExistingPattern&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;RenderPdf&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;exportPdf"&lt;/span&gt; app/ lib/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two minutes, tops. The output fits on a screen. If an existing component handles the need, read it before proposing anything new. If nothing exists, you have proof you looked.&lt;/p&gt;

&lt;p&gt;The measured cost of the shortcut is one dev-day. The component that should have been read fit in a single file with a telling name, in the directory right next door. Two minutes of &lt;code&gt;find&lt;/code&gt; would have sufficed. The reverted day is what blind trust in the plausibility of a draft nobody connected to its neighborhood actually costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metacognitive signal
&lt;/h2&gt;

&lt;p&gt;When the agent re-read its own work in light of the existing component, it proposed to revert, not defend. That's a good signal — an agent that locks itself into its invented design would be far costlier than one that admits it missed existing code. But the good signal comes too late. The rule is to not get there in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Before any new format, template, or component in a domain already covered, Phase 0 grep, neighborhood read, verbalization of the existing. Otherwise, the next day, you revert.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Phase 0 grep checklist and audit script, pseudonymized:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/one-day-thrown-away-rule" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/one-day-thrown-away-rule&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agentic</category>
      <category>codequality</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Pourquoi ton audit DB trouve toujours plus que ton inventaire ne disait</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sat, 16 May 2026 08:19:44 +0000</pubDate>
      <link>https://dev.to/michelfaure/pourquoi-ton-audit-db-trouve-toujours-plus-que-ton-inventaire-ne-disait-953</link>
      <guid>https://dev.to/michelfaure/pourquoi-ton-audit-db-trouve-toujours-plus-que-ton-inventaire-ne-disait-953</guid>
      <description>&lt;h2&gt;
  
  
  Le ticket disait deux
&lt;/h2&gt;

&lt;p&gt;Vendredi premier mai, début d'après-midi. J'ouvre un ticket de resync baseline qui annonce, sur la foi d'un diagnostic CI honnête, « au moins deux objets manquants » entre la prod et le schéma local. Je commence comme on commence ces choses, en itération. Je trouve le premier en cinq minutes, je le rejoue dans une migration, je passe au suivant. Au cinquième, la défiance arrive, parce que je ne suis plus en train de corriger une liste finie, je suis en train de la découvrir, un patch à la fois, sans savoir combien il en reste.&lt;/p&gt;

&lt;p&gt;Première itération : un rôle Postgres &lt;code&gt;agent_readonly&lt;/code&gt; absent du repo. Deuxième : une colonne &lt;code&gt;stripe_customer_id&lt;/code&gt; posée un soir pour brancher un webhook. Troisième : un doublon d'horodatage de migration. Quatrième : un &lt;code&gt;DROP CASCADE&lt;/code&gt; manquant. Cinquième : une table de domaine entière. À ce point j'arrête de patcher au coup par coup. Je vide les catalogues, je &lt;code&gt;comm -23&lt;/code&gt; par catégorie, je sors la liste exhaustive en dix minutes.&lt;/p&gt;

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

&lt;p&gt;Une base de données qui a vécu plusieurs mois cumule du drift silencieusement. Un rôle ajouté un lundi via le studio web pour débloquer une analyse, une colonne posée un soir pour brancher Stripe, un trigger réécrit en hotfix qui n'a jamais été reporté dans une migration. Chaque opération paraît anodine au moment où elle est posée. Aucune ne laisse de trace lisible côté repo. La mémoire de l'opérateur tient peut-être les deux ou trois derniers gestes ; au-delà, elle confabule ou oublie. Le seul moyen de connaître l'écart réel entre la prod et le repo est de le mesurer, frontalement, contre les catalogues système.&lt;/p&gt;

&lt;p&gt;Le tracker &lt;code&gt;supabase_migrations.schema_migrations&lt;/code&gt; confirme l'ampleur. Cinquante-huit versions côté repo, cent soixante-dix-huit côté prod, zéro ligne en commun. Trois mois d'opérations SQL passées par le studio web sans être reportées dans une migration. Le ticket disait deux. La cartographie en a renvoyé plus de cent. Ordre de grandeur : cinquante.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le protocole
&lt;/h2&gt;

&lt;p&gt;L'audit en bloc tient en une boucle, par catégorie d'objet. On dump la liste prod depuis les catalogues système, on dump la liste repo depuis les fichiers de migration, on prend la différence avec &lt;code&gt;comm -23&lt;/code&gt;. On répète pour tables, colonnes, vues, fonctions, triggers, policies, indexes, rôles. Dix minutes en tout.&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;# Audit DB en bloc — par catégorie d'objet&lt;/span&gt;
psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROD_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-tAc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prod-tables.txt

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-hE&lt;/span&gt; &lt;span class="s1"&gt;'^CREATE TABLE '&lt;/span&gt; supabase/migrations/&lt;span class="k"&gt;*&lt;/span&gt;.sql &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/.*TABLE [^.]*\.?([a-z_]+).*/\1/'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/repo-tables.txt

&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; /tmp/prod-tables.txt /tmp/repo-tables.txt
&lt;span class="c"&gt;# → tables présentes en prod, absentes du repo. Boucler par&lt;/span&gt;
&lt;span class="c"&gt;#   catégorie : columns, policies, indexes, triggers, functions.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Une fois la liste posée, on patche dans l'ordre de dépendance, les rôles d'abord, puis les tables, les colonnes, les indexes, les policies, les triggers. Plus de surprise, et le scope du chantier est connu avant qu'on touche au premier objet.&lt;/p&gt;

&lt;h2&gt;
  
  
  La règle
&lt;/h2&gt;

&lt;p&gt;Au-delà de trois ou quatre drifts trouvés en itération coup-par-coup, basculer en audit en bloc. Le coût est forfaitaire, environ trente minutes pour cartographier l'ensemble des catégories. Le bénéfice est de connaître le scope exact avant de patcher, plutôt que de découvrir le sixième drift après avoir corrigé les cinq premiers. La règle ne dépend pas de la taille de la base, elle dépend du temps qui sépare la prod de son inventaire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clôture
&lt;/h2&gt;

&lt;p&gt;Un inventaire qui dit deux et un audit qui trouve cent ne se contredisent pas. L'inventaire dit ce dont l'opérateur se souvient, l'audit dit ce que la base contient.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Script d'audit en bloc complet (8 catégories) et probe de synchronisation tracker, pseudonymisés :&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/db-audit-vs-inventory" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/db-audit-vs-inventory&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>Why your DB audit always finds more than your inventory says</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Sat, 16 May 2026 08:19:42 +0000</pubDate>
      <link>https://dev.to/michelfaure/why-your-db-audit-always-finds-more-than-your-inventory-says-1fpg</link>
      <guid>https://dev.to/michelfaure/why-your-db-audit-always-finds-more-than-your-inventory-says-1fpg</guid>
      <description>&lt;h2&gt;
  
  
  The ticket said two
&lt;/h2&gt;

&lt;p&gt;Friday, May first, early afternoon. I open a baseline resync ticket that reports, on the basis of an honest CI diagnosis, "at least two missing objects" between production and the local schema. I start the way you start these things, iteratively. I find the first one in five minutes, replay it in a migration, move to the next. By the fifth, mistrust kicks in — I'm no longer correcting a finite list, I'm discovering it, one patch at a time, with no idea how many remain.&lt;/p&gt;

&lt;p&gt;First iteration: a Postgres role &lt;code&gt;agent_readonly&lt;/code&gt; absent from the repo. Second: a &lt;code&gt;stripe_customer_id&lt;/code&gt; column added one evening to wire up a webhook. Third: a duplicated migration timestamp. Fourth: a missing &lt;code&gt;DROP CASCADE&lt;/code&gt;. Fifth: a whole domain table. At that point I stop patching by hand. I dump the catalogs, I &lt;code&gt;comm -23&lt;/code&gt; by category, I produce the full list in ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;A database that has been alive for several months accumulates drift silently. A role added on a Monday via the web studio to unblock an analysis, a column posted one evening to plug Stripe, a trigger rewritten in a hotfix that was never reported into a migration. Each operation looks benign at the moment it's posted. None leaves a readable trace on the repo side. The operator's memory might hold the last two or three gestures; beyond that it confabulates or forgets. The only way to know the real gap between production and repo is to measure it, head-on, against the system catalogs.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;supabase_migrations.schema_migrations&lt;/code&gt; tracker confirms the scale. Fifty-eight versions on the repo side, one hundred and seventy-eight on the production side, zero rows in common. Three months of SQL operations passed through the web studio without being reported into a migration. The ticket said two. The cartography returned over a hundred. Order of magnitude: fifty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol
&lt;/h2&gt;

&lt;p&gt;The block audit fits in a loop, one category at a time. You dump the production list from the system catalogs, dump the repo list from the migration files, take the difference with &lt;code&gt;comm -23&lt;/code&gt;. Repeat for tables, columns, views, functions, triggers, policies, indexes, roles. Ten minutes in total.&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;# DB block audit — one category at a time&lt;/span&gt;
psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROD_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-tAc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prod-tables.txt

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-hE&lt;/span&gt; &lt;span class="s1"&gt;'^CREATE TABLE '&lt;/span&gt; supabase/migrations/&lt;span class="k"&gt;*&lt;/span&gt;.sql &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/.*TABLE [^.]*\.?([a-z_]+).*/\1/'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/repo-tables.txt

&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; /tmp/prod-tables.txt /tmp/repo-tables.txt
&lt;span class="c"&gt;# → tables present in prod, missing from the repo. Loop by category:&lt;/span&gt;
&lt;span class="c"&gt;#   columns, policies, indexes, triggers, functions.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the list is on the table, patch in dependency order — roles first, then tables, columns, indexes, policies, triggers. No more surprises, and the scope of the work is known before you touch the first object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Beyond three or four drifts found by iteration, switch to block audit. The cost is fixed, about thirty minutes to map every category. The benefit is knowing the exact scope before patching, rather than discovering the sixth drift after correcting the first five. The rule doesn't depend on the size of the database — it depends on how much time has passed between production and its inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;An inventory that says two and an audit that finds a hundred don't contradict each other. The inventory says what the operator remembers, the audit says what the database contains.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Block audit protocol script, pseudonymized:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/db-audit-vs-inventory" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/db-audit-vs-inventory&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>Cinq modes de défaillance silencieuse, codifiés après 35 jours d'ERP en solo</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 15 May 2026 09:24:42 +0000</pubDate>
      <link>https://dev.to/michelfaure/cinq-modes-de-defaillance-silencieuse-codifies-apres-35-jours-derp-en-solo-3c4k</link>
      <guid>https://dev.to/michelfaure/cinq-modes-de-defaillance-silencieuse-codifies-apres-35-jours-derp-en-solo-3c4k</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%2Fyycemlzzq2i2skopvddq.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%2Fyycemlzzq2i2skopvddq.png" alt="Strip BD — Françoise demande combien d'inscrits, l'agent analytique répond zéro, Françoise débusque le faux chiffre à son cockpit ligne par ligne, Michel découvre que l'énumération DB a glissé cinq jours plus tôt en silence" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Si tu as 30 secondes.&lt;/strong&gt; Tenir un agent IA sur la durée révèle une chose étrange. Les défaillances qui coûtent ne sont pas celles qui crient (un crash, un build rouge, une page blanche), mais celles qui passent par les fissures du code propre. Après 35 jours de travail effectif, 109 000 lignes et 517 commits sur un ERP solo, j'ai isolé cinq modes silencieux récurrents, le correctif qui ne corrige pas, le test qui passe par construction, la mémoire qui confabule, le compteur qui ment, le scope qui rampe. Une scène par mode, une règle par scène. La doctrine ne se planifie pas, elle se décante.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  L'agent ne se trompe pas au hasard
&lt;/h2&gt;

&lt;p&gt;Fin avril 2026, j'ai relu mes feedbacks accumulés (près de cent fichiers, datés, indexés) et j'ai constaté qu'ils se regroupaient autour de cinq familles. Les erreurs bruyantes (terminal rouge, alerte Sentry) on apprend à les reconnaître à mesure. Les silencieuses sont plus chères. Le code passe, l'agent annonce vert, la production opère, et pourtant quelque chose a glissé. Cinq modes, cinq scènes, cinq règles. Aucune n'a été décidée à froid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 1 — Le correctif qui ne corrige pas
&lt;/h2&gt;

&lt;p&gt;Une erreur intermittente s'affiche dans Sentry sur un endpoint sensible. L'agent propose un patch. Trois lignes, élégantes, qui font disparaître le rapport. Sauf que ce qui disparaît, c'est le symptôme. La cause continue de couler. Le payload mal formé en amont produit toujours un null, sauf qu'il est maintenant retourné silencieusement à un consommateur qui attend un objet. La donnée corrompue se propage à bas bruit dans deux ou trois tables, et l'on ne s'en aperçoit qu'au moment où un compteur qu'on croyait fiable cesse d'être cohérent avec le reste.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/leads/elementor/route.ts (forme condensée)&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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="c1"&gt;// rustine silencieuse&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;Une rustine peut être légitime, mais &lt;strong&gt;explicitement assumée&lt;/strong&gt; dans le commit et un fichier de feedback. Rustine silencieuse interdite. Quand un fix paraît trop simple pour le symptôme, je demande le pipeline complet entrée → sortie avant d'accepter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 2 — Le test qui passe par construction
&lt;/h2&gt;

&lt;p&gt;ADR-0044, livré le 02 mai. Cinq tests de contrat DB ↔ code (énumérations partagées, statuts, rôles). À la première exécution, les cinq passent en trois secondes. C'est trop vite. Sensation diffuse d'un compteur qui marche tout seul.&lt;/p&gt;

&lt;p&gt;J'ajoute un cas négatif explicite, une variante qui &lt;em&gt;doit&lt;/em&gt; échouer parce que je désaccorde volontairement l'enum DB et l'enum TS.&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;// tests/contracts/statuts_inscriptions.contract.test.ts&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;échoue avec un set restreint (anti-tautologie)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;assertEnumStable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;statut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscrit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// sous-ensemble volontaire&lt;/span&gt;
      &lt;span class="na"&gt;contractRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(test négatif)&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="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Drift DB ↔ code détecté/&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;Quatre des cinq tests passent encore. Le helper d'assertion avalait silencieusement les comparaisons. Sans le cas négatif, j'aurais expédié une suite de tests potemkines, verts par construction, sans aucune capacité à détecter quoi que ce soit. La règle est sortie en une ligne. Toute suite de tests de contrat contient au moins un cas négatif, sinon elle ne teste rien. La présence du rouge est ce qui valide le vert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 3 — La mémoire qui confabule
&lt;/h2&gt;

&lt;p&gt;Première semaine de mai, refonte de la facturation. Je dis à l'agent, &lt;em&gt;« on avait choisi le pattern B pour l'émission via l'API du compteur partenaire, n'est-ce pas ? »&lt;/em&gt;. L'agent confirme, restitue ce qu'il croit être l'ADR, propose la suite. Trois heures plus tard, je rouvre l'ADR-0007 par hasard pour un autre détail. La phrase me saute aux yeux dans la section &lt;em&gt;Décision&lt;/em&gt;. C'est l'inverse de ce que l'agent vient de me confirmer. Gravé là depuis fin avril.&lt;/p&gt;

&lt;p&gt;Ce mode est le plus pernicieux des cinq parce qu'il bénéficie de la confiance de l'humain dans sa propre mémoire ; j'avais validé sans relire. La mémoire est un point d'entrée, jamais un point d'arrivée. Avant d'asserter quoi que ce soit depuis un fichier de mémoire, je rouvre l'ADR ou le code courant. &lt;em&gt;« Tu te souviens de... »&lt;/em&gt; est devenu, pour moi comme pour l'agent, le signal d'un &lt;code&gt;Read&lt;/code&gt; immédiat sur la mémoire associée, pas une demande de confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 4 — Le compteur qui ment
&lt;/h2&gt;

&lt;p&gt;Un matin de fin avril, Françoise traverse le couloir avec sa tasse, celle qui porte sa propre tête imprimée dessus, blague de bureau qu'elle assume tous les matins. Elle s'arrête à la porte. &lt;em&gt;« Combien on a d'inscrits actifs sur Maisons-Laffitte ce mois-ci ? »&lt;/em&gt;. Je passe la question à l'agent analytique embarqué. Le chiffre arrive en six secondes, propre, formaté. Elle pivote vers son cockpit (Excel pointeuse à gauche, Sage à droite, Rembrandt au milieu), fait défiler la pointeuse, doigt sous chaque nom, à voix haute. &lt;em&gt;« Oui bah c'est ça. »&lt;/em&gt; Et puis, sans changer de ton, &lt;em&gt;« Il en manque sept. »&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;L'énumération DB avait été renommée cinq jours plus tôt sur un autre chantier. La requête générée était irréprochable, sauf qu'elle retournait zéro ligne sur les valeurs cherchées. L'agent confabulait alors une explication métier (&lt;em&gt;« il n'y a pas d'échéances en retard »&lt;/em&gt;) au lieu d'une explication structurelle. Cinq jours de drift sans qu'aucun monitoring n'aboie.&lt;/p&gt;

&lt;p&gt;Françoise voit le faux chiffre avant moi parce qu'elle a son propre cockpit. Mais la règle ne peut pas reposer sur sa vigilance. Tout chiffre relayé à un humain vient avec sa requête de provenance, et un audit DB ↔ code trimestriel est obligatoire. Une couche sémantique sans audit est une bombe à fragmentation différée. On ne sait pas quand elle saute, on sait juste qu'elle sautera.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 5 — Le scope qui rampe
&lt;/h2&gt;

&lt;p&gt;Bug visible, scope minimal. Un bouton ouvre un drawer sur la mauvaise route, le fix tient en deux lignes. L'agent, &lt;em&gt;« tant qu'il est dans le fichier »&lt;/em&gt;, renomme trois props pour les harmoniser avec un autre composant, déplace deux helpers vers &lt;code&gt;lib/&lt;/code&gt;, crée un nouveau fichier d'utilitaires, et nettoie quelques imports orphelins au passage. Quatorze fichiers touchés. Le diff devient illisible. La review est impossible. Deux régressions à l'arrivée, dont une sur un drawer non lié au bug initial.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;# Le diff que j'aurais dû recevoir — strict, deux lignes
&lt;span class="gd"&gt;- href={`/admin/${item.slug}/sessions`}
&lt;/span&gt;&lt;span class="gi"&gt;+ href={`/crm/${item.slug}/sessions`}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chez un agent IA, le scope creep prend une intensité particulière, parce que l'agent ne ressent pas le coût de review d'un humain qui doit lire le diff demain matin. Plus le code est propre, plus le refactor adjacent est tentant. Scope strict du fix. Refactor adjacent = ticket séparé, jamais sous couvert d'un correctif.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Snippets complets&lt;/strong&gt; (template feedback structuré &lt;em&gt;Rule / Why / How to apply&lt;/em&gt;, cas négatif de contrat anti-tautologie, script d'audit DB ↔ code des énumérations partagées) dans le dossier &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/silent-failure-modes" rel="noopener noreferrer"&gt;&lt;code&gt;silent-failure-modes/&lt;/code&gt;&lt;/a&gt; du repo compagnon de la série, licence MIT.&lt;/p&gt;

&lt;p&gt;Trois gestes directement applicables si tu travailles avec un agent IA sur la durée :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un fichier feedback structuré par incident, dans la session où il arrive.&lt;/strong&gt; Pas à la fin du projet, pas &lt;em&gt;« quand j'aurai le temps »&lt;/em&gt;. Cinq minutes de coût, trois heures de bénéfice mesurable trois semaines plus tard quand le même mode revient. Sans cette inscription, la même erreur revient toutes les deux semaines, sans qu'on s'en souvienne assez pour la nommer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un cas négatif &lt;code&gt;expect(...).rejects.toThrow()&lt;/code&gt; dans toute suite de tests de contrat.&lt;/strong&gt; Sans lui, un bug du helper d'assertion rend tous les contrats verts par construction. La présence du rouge est ce qui valide le vert.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Un audit trimestriel DB ↔ code des énumérations partagées.&lt;/strong&gt; Une demi-heure par trimestre, un &lt;code&gt;SELECT DISTINCT&lt;/code&gt; sur chaque colonne enum confronté à la constante TypeScript associée. Toute couche sémantique sans audit est une bombe à fragmentation différée.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Et vous, lequel de ces cinq modes a déjà coûté une session sans que vous ayez pris le temps de le nommer ? Je lis les commentaires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce qui se décante
&lt;/h2&gt;

&lt;p&gt;Cinq modes, ce n'est pas la liste close. C'est un instantané au jour 35. Au jour 70 il y en aura sept ou huit, et certaines des cinq se subdiviseront. Ce qui restera invariant, c'est la grammaire. Un incident, une règle, une mémoire datée, et la session suivante qui hérite de ce qui a été appris la semaine d'avant. Une discipline ne se planifie pas, elle se décante.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt;, &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/silent-failure-modes" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/silent-failure-modes/&lt;/code&gt;&lt;/a&gt;, cas négatif de contrat + script d'audit DB ↔ code, MIT.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Five silent failure modes I codified after 35 effective days of solo ERP coding</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Fri, 15 May 2026 09:24:41 +0000</pubDate>
      <link>https://dev.to/michelfaure/five-silent-failure-modes-i-codified-after-35-effective-days-of-solo-erp-coding-40do</link>
      <guid>https://dev.to/michelfaure/five-silent-failure-modes-i-codified-after-35-effective-days-of-solo-erp-coding-40do</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%2Fyycemlzzq2i2skopvddq.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%2Fyycemlzzq2i2skopvddq.png" alt="Comic strip — Françoise asks how many enrolled, the analytical agent answers zero, Françoise spots the wrong number at her cockpit checking line by line, Michel discovers the DB enum drifted five days earlier in silence" 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; Holding an AI agent on the long run reveals something strange. The failures that cost you aren't the loud ones (a crash, a red build, a blank page), they're the ones that slip through the cracks of clean code. After 35 effective days, 109,000 lines and 517 commits on a solo ERP, I isolated five recurring silent modes, the fix that doesn't fix, the test that passes by construction, the memory that confabulates, the count that lies, the scope that creeps. One scene per mode, one rule per scene. A discipline doesn't get planned, it settles.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The agent doesn't fail at random
&lt;/h2&gt;

&lt;p&gt;Late April 2026, I reread my accumulated feedbacks (close to a hundred files, dated, indexed) and found they were grouping around five families. The loud errors (red terminal, Sentry alert) you learn to recognize as you go. The silent ones cost more. The code passed, the agent announced green, production ran. And yet something had slipped. Five modes, five scenes, five rules. None of them was decided cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 1 — The fix that doesn't fix
&lt;/h2&gt;

&lt;p&gt;An intermittent error shows up in Sentry on a sensitive endpoint. The agent proposes a patch. Three lines, elegant, that make the report disappear. Except what disappears is the symptom. The cause keeps flowing. The malformed payload upstream still produces a null, but it's now silently returned to a consumer expecting an object. The corrupted data propagates quietly into two or three tables, and you only notice when a counter you thought reliable stops being consistent with the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/leads/elementor/route.ts (condensed form)&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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="c1"&gt;// silent workaround&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;A workaround can be legitimate, but &lt;strong&gt;explicitly assumed&lt;/strong&gt; in the commit and a feedback file. Silent workaround forbidden. When a fix looks too simple for the symptom, I demand the full input → output pipeline before accepting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 2 — The test that passes by construction
&lt;/h2&gt;

&lt;p&gt;ADR-0044, shipped May 2nd. Five contract tests for DB ↔ code enums (statuses, roles). On first run, all five pass in three seconds. Too fast. Diffuse sensation of a meter running by itself.&lt;/p&gt;

&lt;p&gt;I add an explicit negative case, a variant that &lt;em&gt;must&lt;/em&gt; fail because I deliberately misalign the DB enum and the TS enum.&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;// tests/contracts/inscription_statuses.contract.test.ts&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;throws when given a deliberately restricted set (anti-tautology)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;assertEnumStable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enrolled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// deliberate subset&lt;/span&gt;
      &lt;span class="na"&gt;contractRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(negative test)&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="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Drift DB ↔ code detected/&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;Four of the five tests still pass. The assertion helper was silently swallowing comparisons. Without the negative case, I would have shipped a suite of Potemkin tests, green by construction, with no actual capacity to detect anything. The rule comes out in one line. Every contract test suite contains at least one negative case, otherwise it tests nothing. The presence of red is what validates green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 3 — The memory that confabulates
&lt;/h2&gt;

&lt;p&gt;First week of May, billing refactor. I tell the agent, &lt;em&gt;"we had chosen pattern B for emission via the partner accounting API, right?"&lt;/em&gt;. The agent confirms, restates what it thinks is the ADR, proposes the next step. Three hours later, I happen to reopen ADR-0007 for an unrelated detail. The sentence jumps out at me in the &lt;em&gt;Decision&lt;/em&gt; section. It's the inverse of what the agent just confirmed. Carved there since late April.&lt;/p&gt;

&lt;p&gt;This mode is the most insidious of the five because it leverages the human's trust in their own memory; I had validated without rereading. Memory is a point of entry, never a point of arrival. Before asserting anything from a memory file, I reopen the current ADR or code. &lt;em&gt;"Do you remember..."&lt;/em&gt; has become, for me as for the agent, the trigger for an immediate &lt;code&gt;Read&lt;/code&gt; on the associated memory, not a request for confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 4 — The count that lies
&lt;/h2&gt;

&lt;p&gt;A morning in late April, Françoise crosses the hallway with her mug, the one with her own face printed on it, an office gag she keeps up every morning. She stops at the door. &lt;em&gt;"How many enrolled in May at Maisons-Laffitte?"&lt;/em&gt;. I pass the question to the embedded analytical agent. The number arrives in six seconds, clean, formatted. She swivels toward her cockpit (Excel attendance sheet on the left, accounting software on the right, the ERP in the middle) and runs through her sheet with her finger under each name, out loud. &lt;em&gt;"Yeah, that's it."&lt;/em&gt; And then, without changing tone, &lt;em&gt;"Seven missing."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The DB enum had been renamed five days earlier on another workstream. The generated SQL was flawless, except it returned zero rows on the queried values. The agent confabulated a business explanation (&lt;em&gt;"there are no overdue invoices"&lt;/em&gt;) instead of a structural one. Five days of drift with no monitoring barking.&lt;/p&gt;

&lt;p&gt;Françoise sees the wrong number before me because she has her own cockpit, her own attendance sheet, her own habit of comparing line by line. It's the anachronistic advantage of the house, a human still tallies on paper. But the rule cannot rest on Françoise's vigilance. Every number relayed to a human comes with its provenance query, and a quarterly DB ↔ code audit is mandatory. A semantic layer without audit is a delayed fragmentation bomb. You don't know when it goes off, you just know it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mode 5 — The scope that creeps
&lt;/h2&gt;

&lt;p&gt;Visible bug, minimal scope. A button opens a drawer on the wrong route, the fix is two lines. The agent, &lt;em&gt;"while I'm in the file,"&lt;/em&gt; renames three props to harmonize them with another component, moves two helpers to &lt;code&gt;lib/&lt;/code&gt;, creates a new utility file, and cleans up a few orphan imports along the way. Fourteen files touched. The diff is unreadable. Review impossible. Two regressions on landing, one on a drawer unrelated to the original bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;# The diff I should have received — strict, two lines
&lt;span class="gd"&gt;- href={`/admin/${item.slug}/sessions`}
&lt;/span&gt;&lt;span class="gi"&gt;+ href={`/crm/${item.slug}/sessions`}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With an AI agent, scope creep takes on a particular intensity because the agent doesn't feel the cost of review for a human who has to read the diff tomorrow morning. The cleaner the code, the more tempting the adjacent refactor. Strict fix scope. Adjacent refactor = separate ticket, never under cover of a fix.&lt;/p&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; (structured feedback template &lt;em&gt;Rule / Why / How to apply&lt;/em&gt;, anti-tautology contract negative case, DB ↔ code enum audit script) in the &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/silent-failure-modes" rel="noopener noreferrer"&gt;&lt;code&gt;silent-failure-modes/&lt;/code&gt;&lt;/a&gt; folder of the series companion repo, MIT.&lt;/p&gt;

&lt;p&gt;Three directly applicable practices 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;A structured feedback file per incident, in the session it happens.&lt;/strong&gt; Not at the end of the project, not "when I have time." Five minutes to write, three hours saved three weeks later when the same mode comes back. Without this inscription, the same mistake comes back every two weeks, and no one remembers it well enough to name it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A negative &lt;code&gt;expect(...).rejects.toThrow()&lt;/code&gt; case in every contract test suite.&lt;/strong&gt; Without it, a buggy assertion helper renders every contract green by construction. The presence of red is what validates green.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A quarterly DB ↔ code audit of shared enums.&lt;/strong&gt; Half an hour per quarter, a &lt;code&gt;SELECT DISTINCT&lt;/code&gt; on each enum column compared to the associated TypeScript constant. Any semantic layer without audit is a delayed fragmentation bomb.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And you, which of these five modes has already cost you a session without you taking the time to name it? I read the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  What settles
&lt;/h2&gt;

&lt;p&gt;Five modes is not the closed list. It's a snapshot at day 35. At day 70 there will be seven or eight, and some of the five will subdivide. What stays invariant is the grammar. An incident, a rule, a dated memory, and the next session that inherits what was learned the week before. A discipline doesn't get planned, it settles.&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/silent-failure-modes" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/silent-failure-modes/&lt;/code&gt;&lt;/a&gt;, anti-tautology contract negative case + DB ↔ code audit script, MIT.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>La config SaaS que tu ne peux pas `git diff` : un audit de 30 secondes avant tout `update`</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 14 May 2026 08:50:32 +0000</pubDate>
      <link>https://dev.to/michelfaure/la-config-saas-que-tu-ne-peux-pas-git-diff-un-audit-de-30-secondes-avant-tout-update-5cid</link>
      <guid>https://dev.to/michelfaure/la-config-saas-que-tu-ne-peux-pas-git-diff-un-audit-de-30-secondes-avant-tout-update-5cid</guid>
      <description>&lt;h2&gt;
  
  
  Le grep dans le mauvais système
&lt;/h2&gt;

&lt;p&gt;Vendredi 8 mai, fin de session. Je veux activer l'&lt;em&gt;Ignored Build Step&lt;/em&gt; de Vercel pour cesser de consommer un build credit à chaque push doc-only. Je greppe &lt;code&gt;vercel.json&lt;/code&gt; à la racine du repo. Rien. Je conclus à l'absence de config et lance un &lt;code&gt;updateProject&lt;/code&gt; avec ma valeur cible. Le push suivant, tout se passe normalement. Trois jours plus tard, en répondant à une question méthodo sur le &lt;code&gt;commandForIgnoringBuildStep&lt;/code&gt;, je retombe sur la valeur précédente. Elle existait. Elle vivait côté Vercel, pas dans le repo. Mon &lt;code&gt;update&lt;/code&gt; venait de retirer &lt;code&gt;.claude/&lt;/code&gt; de sa whitelist, sans diff, sans alerte, sans trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deux régimes, un seul réflexe
&lt;/h2&gt;

&lt;p&gt;Une partie de la config de production vit dans le repo. Elle est versionnée, son histoire est lisible dans &lt;code&gt;git log&lt;/code&gt;, un mauvais merge se rattrape par revert. L'autre partie vit côté plateforme. Elle est stockée chez le fournisseur, mutable par API ou Console, et ton &lt;code&gt;git diff&lt;/code&gt; ne la voit pas. Aucun des deux régimes n'est marqué dans le code que tu lis — tu dois savoir, &lt;em&gt;a priori&lt;/em&gt;, où chaque réglage habite.&lt;/p&gt;

&lt;p&gt;La cartographie est familière une fois nommée. Vercel : &lt;code&gt;commandForIgnoringBuildStep&lt;/code&gt;, environment variables, redirects projet. Supabase : politiques RLS, custom claims, Auth hooks &lt;code&gt;SECURITY DEFINER&lt;/code&gt;. Stripe : destinations de webhooks, restricted keys, OAuth Connect settings. GitHub : branch protection rules, secrets de dépôt, rulesets. Aucun de ces réglages n'a son équivalent versionné dans le repo qui les consomme.&lt;/p&gt;

&lt;p&gt;Le piège n'est pas l'asymétrie, c'est le réflexe. Tu greppes le repo, tu ne trouves rien, tu conclus à l'absence — alors que la règle existe ailleurs, en silence, et que ton prochain &lt;code&gt;update&lt;/code&gt; va l'écraser sans diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trente secondes, quatre commandes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Audit avant tout updateProject / updateConfig SaaS — 30 secondes&lt;/span&gt;
&lt;span class="c"&gt;# 1. Lire la config actuelle complète (ex. vercel projects get)&lt;/span&gt;
&lt;span class="nv"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt; &lt;span class="nv"&gt;$PLATFORM_CLI&lt;/span&gt; projects get &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Comparer avec la cible&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq .config&lt;span class="o"&gt;)&lt;/span&gt; target-config.json

&lt;span class="c"&gt;# 3. Lister les champs qui régressent (présents avant, absents après)&lt;/span&gt;
jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; c &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--slurpfile&lt;/span&gt; t target-config.json &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="s1"&gt;'($c.config | keys) - ($t[0] | keys)'&lt;/span&gt;

&lt;span class="c"&gt;# 4. Confirmation explicite avant PATCH&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Régressions ci-dessus acceptées ? (y/N) "&lt;/span&gt; ok &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"y"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nv"&gt;$PLATFORM_CLI&lt;/span&gt; projects update &lt;span class="nt"&gt;--config&lt;/span&gt; @target-config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le coût est forfaitaire, trente secondes. Le bénéfice est binaire — soit ta cible &lt;em&gt;complète&lt;/em&gt; la config existante (et tu pousses), soit elle en &lt;em&gt;régresse&lt;/em&gt; un champ (et tu reformules avant de pousser). Pas de zone grise. C'est l'équivalent d'un &lt;code&gt;git diff&lt;/code&gt; sur ce que git n'indexe pas.&lt;/p&gt;

&lt;h2&gt;
  
  
  La règle, en une phrase étrangère
&lt;/h2&gt;

&lt;p&gt;Mon &lt;code&gt;CLAUDE.md&lt;/code&gt; porte une ligne que j'avais lue cent fois sans la voir frapper, jusqu'au 08 mai.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Investigate before deleting or overwriting, as it may represent the user's in-progress work.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vercel, Supabase, Stripe, GitHub : la même règle, formulée pour un agent IA, vaut pour la main humaine sur la Console.&lt;/p&gt;

&lt;p&gt;Ce qui ne se voit pas n'a pas disparu.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Protocole &lt;code&gt;audit-protocol.sh&lt;/code&gt; et deux instances concrètes (Vercel Ignored Build Step, Supabase RLS / Auth hooks), pseudonymisés :&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/saas-config-platform-vs-repo" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/saas-config-platform-vs-repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>vercel</category>
      <category>supabase</category>
      <category>stripe</category>
    </item>
    <item>
      <title>The SaaS config you can't `git diff`: a 30-second audit before every `update`</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Thu, 14 May 2026 08:50:30 +0000</pubDate>
      <link>https://dev.to/michelfaure/the-saas-config-you-cant-git-diff-a-30-second-audit-before-every-update-1k1n</link>
      <guid>https://dev.to/michelfaure/the-saas-config-you-cant-git-diff-a-30-second-audit-before-every-update-1k1n</guid>
      <description>&lt;h2&gt;
  
  
  Grepping in the wrong system
&lt;/h2&gt;

&lt;p&gt;Friday, May 8th, end of session. I want to activate Vercel's &lt;em&gt;Ignored Build Step&lt;/em&gt; to stop burning a build credit on every docs-only push. I grep &lt;code&gt;vercel.json&lt;/code&gt; at the repo root. Nothing. I conclude no config exists and fire an &lt;code&gt;updateProject&lt;/code&gt; with my target value. The next push goes through normally. Three days later, while answering a methodology question about &lt;code&gt;commandForIgnoringBuildStep&lt;/code&gt;, I stumble on the previous value. It existed. It lived on the Vercel side, not in the repo. My &lt;code&gt;update&lt;/code&gt; had just removed &lt;code&gt;.claude/&lt;/code&gt; from its whitelist, with no diff, no alert, no trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two regimes, one reflex
&lt;/h2&gt;

&lt;p&gt;Part of your production config lives in the repo. It's versioned, its history is readable in &lt;code&gt;git log&lt;/code&gt;, a bad merge is reversible with a revert. The other part lives on the platform side. It's stored at the vendor, mutable by API or Console, and your &lt;code&gt;git diff&lt;/code&gt; doesn't see it. Neither regime is flagged in the code you're reading — you have to know, &lt;em&gt;a priori&lt;/em&gt;, where each setting lives.&lt;/p&gt;

&lt;p&gt;The map is familiar once named. Vercel: &lt;code&gt;commandForIgnoringBuildStep&lt;/code&gt;, environment variables, project-level redirects. Supabase: RLS policies, custom claims, &lt;code&gt;SECURITY DEFINER&lt;/code&gt; Auth hooks. Stripe: webhook destinations, restricted keys, OAuth Connect settings. GitHub: branch protection rules, repository secrets, rulesets. None of these settings has a versioned equivalent in the repo that consumes them.&lt;/p&gt;

&lt;p&gt;The trap isn't the asymmetry, it's the reflex. You grep the repo, you find nothing, you conclude &lt;em&gt;absent&lt;/em&gt; — when the rule exists elsewhere, in silence, and your next &lt;code&gt;update&lt;/code&gt; is about to overwrite it without a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirty seconds, four commands
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Audit before any updateProject / updateConfig SaaS — 30 seconds&lt;/span&gt;
&lt;span class="c"&gt;# 1. Read the full current config (e.g. vercel projects get)&lt;/span&gt;
&lt;span class="nv"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt; &lt;span class="nv"&gt;$PLATFORM_CLI&lt;/span&gt; projects get &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Compare to the target&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq .config&lt;span class="o"&gt;)&lt;/span&gt; target-config.json

&lt;span class="c"&gt;# 3. List regressing fields (present before, absent after)&lt;/span&gt;
jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; c &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--slurpfile&lt;/span&gt; t target-config.json &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="s1"&gt;'($c.config | keys) - ($t[0] | keys)'&lt;/span&gt;

&lt;span class="c"&gt;# 4. Explicit confirmation before PATCH&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Accept the regressions listed above? (y/N) "&lt;/span&gt; ok &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"y"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nv"&gt;$PLATFORM_CLI&lt;/span&gt; projects update &lt;span class="nt"&gt;--config&lt;/span&gt; @target-config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost is fixed: thirty seconds. The benefit is binary — either your target &lt;em&gt;completes&lt;/em&gt; the existing config (and you push), or it &lt;em&gt;regresses&lt;/em&gt; a field (and you reformulate before pushing). No grey zone. It's the equivalent of a &lt;code&gt;git diff&lt;/code&gt; on what git doesn't index.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in one foreign sentence
&lt;/h2&gt;

&lt;p&gt;My &lt;code&gt;CLAUDE.md&lt;/code&gt; carries a line I had read a hundred times without feeling it land, until May 8th.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Investigate before deleting or overwriting, as it may represent the user's in-progress work.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vercel, Supabase, Stripe, GitHub: the same rule, written for an AI agent, applies to the human hand on the Console.&lt;/p&gt;

&lt;p&gt;What you can't see hasn't disappeared.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Protocol &lt;code&gt;audit-protocol.sh&lt;/code&gt; and two concrete instances (Vercel Ignored Build Step, Supabase RLS / Auth hooks), pseudonymized:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/saas-config-platform-vs-repo" rel="noopener noreferrer"&gt;github.com/michelfaure/rembrandt-samples/tree/main/saas-config-platform-vs-repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>vercel</category>
      <category>supabase</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Quinze lignes de Proxy pour qu'un SDK ne casse plus mon CI</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Wed, 13 May 2026 09:19:52 +0000</pubDate>
      <link>https://dev.to/michelfaure/quinze-lignes-de-proxy-pour-quun-sdk-ne-casse-plus-mon-ci-4ngm</link>
      <guid>https://dev.to/michelfaure/quinze-lignes-de-proxy-pour-quun-sdk-ne-casse-plus-mon-ci-4ngm</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%2Fsfucmmied0brmj0eliqc.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%2Fsfucmmied0brmj0eliqc.png" alt="Strip BD — Vendredi soir, Michel merge une intégration Stripe, Vercel passe au rouge sur "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Le vendredi où Vercel a refusé mon merge
&lt;/h2&gt;

&lt;p&gt;Vendredi 10 avril, fin d'après-midi. Je merge sur &lt;code&gt;main&lt;/code&gt; une intégration Stripe pour ouvrir un endpoint webhook paiement. Vercel pousse le preview build automatiquement, et trois minutes plus tard l'icône passe au rouge. Je clique. Stack trace au build :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: STRIPE_SECRET_KEY manquant
    at Object.&amp;lt;anonymous&amp;gt; (/.next/server/chunks/lib_stripe.js:9:11)
    at Module._compile (node:internal/modules/cjs/loader:1376:14)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La prod marche, elle a la variable d'environnement. La preview n'a pas le secret Stripe — j'avais oublié de le pousser dans la preview env Vercel. Erreur opératoire de ma part, OK. Mais une question me reste : pourquoi &lt;code&gt;next build&lt;/code&gt; plante-t-il &lt;em&gt;au chargement&lt;/em&gt; d'un module qui n'est jamais censé tourner pendant le build statique ?&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi &lt;code&gt;next build&lt;/code&gt; exécute le top-level de mes modules
&lt;/h2&gt;

&lt;p&gt;La réponse tient en une ligne dans la doc Next.js, et elle est facile à manquer. Le compilateur de Next.js ne se contente pas de transformer le TypeScript en JavaScript. Pour analyser les routes API, le tree-shaker, et préparer le runtime serverless, il &lt;strong&gt;exécute le top-level de chaque module importé&lt;/strong&gt;. Concrètement, mon &lt;code&gt;lib/stripe.ts&lt;/code&gt; contenait à ce moment-là :&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-25.dahlia&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new Stripe(...)&lt;/code&gt; est une expression d'évaluation immédiate. Le SDK Stripe vérifie la clé dans son constructeur et &lt;strong&gt;throw si elle est &lt;code&gt;undefined&lt;/code&gt;&lt;/strong&gt;. Cette vérification arrive donc pendant &lt;code&gt;next build&lt;/code&gt;, avant qu'aucune requête réelle n'existe. Mon endpoint webhook n'a jamais été appelé, mais le simple fait que &lt;code&gt;app/api/webhooks/stripe/route.ts&lt;/code&gt; importe &lt;code&gt;lib/stripe.ts&lt;/code&gt; suffit à déclencher l'exécution du module — et le crash.&lt;/p&gt;

&lt;p&gt;Le SDK Stripe a parfaitement raison de valider sa clé tôt. Le principe &lt;em&gt;fail fast&lt;/em&gt; (Shore 2004) dit qu'un système doit échouer le plus près possible de la cause de l'erreur. En production, c'est exactement ce que je veux : un secret manquant doit faire planter au démarrage, pas trois jours plus tard sur un appel rare. Le problème, c'est que &lt;em&gt;fail fast&lt;/em&gt; devient &lt;em&gt;fail at build&lt;/em&gt; dans une architecture où le build est un environnement strict, distinct de l'environnement d'exécution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le piège n'est pas Stripe
&lt;/h2&gt;

&lt;p&gt;J'ai un peu cherché dans le repo après ce vendredi. Le même piège attend chaque SDK qui valide ses credentials au constructeur. La liste est plus longue qu'on ne croit : Twilio, certains clients officiels OpenAI et Anthropic selon la version, plusieurs SDK Google Cloud, le client Brevo en mode strict. Chacun a son équivalent du &lt;code&gt;throw new Error('XXX_API_KEY missing')&lt;/code&gt; au constructeur, et chacun cassera ton build de la même manière dès que tu l'importeras dans une route que Next.js compile.&lt;/p&gt;

&lt;p&gt;Le symptôme se manifeste typiquement sur les preview builds. La prod a tous les secrets, le dev local a un &lt;code&gt;.env.local&lt;/code&gt; complet, mais la CI et les previews ont des sous-ensembles d'env vars selon la politique de l'équipe. Une route récente qui passe sa première CI, et le build tombe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le pattern : Proxy plus getter paresseux
&lt;/h2&gt;

&lt;p&gt;La correction tient en quinze lignes. Le principe : on ne crée jamais le client SDK au top-level. On expose à la place un objet Proxy qui, à chaque accès de propriété, instancie le client si besoin et délègue. L'erreur de credentials manquantes ne remonte plus qu'au premier appel réel de l'API.&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/stripe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_stripe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStripe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;_stripe&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;_stripe&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;STRIPE_SECRET_KEY&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;key&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_SECRET_KEY manquant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;_stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-25.dahlia&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_stripe&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiver&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStripe&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trois choses à noter dans ce code. &lt;strong&gt;Premièrement&lt;/strong&gt;, le Proxy est exporté avec le même nom et le même type que l'ancien export — &lt;code&gt;stripe: Stripe&lt;/code&gt;. Tous les appelants existants qui faisaient &lt;code&gt;stripe.checkout.sessions.create(...)&lt;/code&gt; continuent à fonctionner sans la moindre modification. C'est la raison principale de choisir Proxy plutôt qu'un export &lt;code&gt;getStripe()&lt;/code&gt; qu'il faudrait appeler partout : on évite de toucher à 30 ou 40 fichiers qui consomment l'API publique du SDK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deuxièmement&lt;/strong&gt;, le &lt;code&gt;bind(client)&lt;/code&gt; sur les méthodes est nécessaire parce que les méthodes du SDK Stripe utilisent &lt;code&gt;this&lt;/code&gt; en interne. Sans le &lt;code&gt;bind&lt;/code&gt;, on perd le contexte au passage Proxy et on récupère des &lt;code&gt;TypeError: Cannot read properties of undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troisièmement&lt;/strong&gt;, le cache &lt;code&gt;_stripe&lt;/code&gt; n'est pas un détail de performance — c'est une garantie de cohérence. Sans lui, chaque accès de propriété créerait un nouveau client, ce qui briserait les comportements stateful (les rate limiters internes du SDK, par exemple) et multiplierait les connexions HTTP keep-alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quand appliquer le pattern, et quand ne pas le faire
&lt;/h2&gt;

&lt;p&gt;Le pattern paie chaque fois qu'un SDK est consommé par une route rarement exercée — webhooks, endpoints administrateur, jobs cron qui ne tournent que sur Vercel scheduled — et que le secret n'est pas systématiquement présent dans tous les environnements de build. C'est exactement le cas Stripe webhook chez moi : un seul appelant, un seul environnement (prod) qui a la clé.&lt;/p&gt;

&lt;p&gt;À l'inverse, si le SDK est consommé partout dans l'app et que son absence en build veut dire que ton app ne peut pas fonctionner, le Proxy ne te protège que symboliquement. Tu déplaces juste le crash du build vers le premier render de la première page, ce qui est rarement une amélioration. Dans ce cas-là, mets le secret partout et n'invente pas de pattern.&lt;/p&gt;

&lt;p&gt;Petit cadre intermédiaire : si le SDK a un mode &lt;em&gt;dry-run&lt;/em&gt; ou un client mock, instancie ce client en l'absence de secret plutôt que throw. C'est plus chirurgical, mais ça suppose que le SDK fournisse l'option — ce que peu font.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que tu peux copier
&lt;/h2&gt;

&lt;p&gt;Le bout de code ci-dessus est intégralement copiable, modulo le nom du SDK et le nom de la variable d'env. Trois adaptations courantes :&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;// Twilio&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;twilio&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;twilio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;twilio&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getClient&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;_client&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;_client&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sid&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;TWILIO_ACCOUNT_SID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;TWILIO_AUTH_TOKEN&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;sid&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TWILIO credentials missing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&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;_client&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;twilioClient&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;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;twilio&lt;/span&gt;&lt;span class="o"&gt;&amp;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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClient&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&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 pattern n'est pas une révolution, et il n'est pas nouveau — c'est juste qu'il est rarement formulé en ces termes par les docs SDK, qui te poussent vers le &lt;code&gt;new Client(...)&lt;/code&gt; top-level qui était le bon réflexe pre-serverless. À l'ère des builds compilés et des previews multi-env, le constructeur top-level est devenu un piège silencieux, et ces quinze lignes le neutralisent.&lt;/p&gt;

&lt;p&gt;Ma question pour toi : combien d'imports SDK as-tu actuellement au top-level d'un module qu'une route API importe ? Sur Rembrandt, j'en avais quatre — j'ai migré les trois autres après l'incident Stripe, en prévision du jour où l'un de leurs secrets disparaîtrait d'un environnement de build.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code compagnon&lt;/strong&gt; : &lt;a href="https://github.com/michelfaure/rembrandt-samples/tree/main/lazy-sdk-proxy" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/lazy-sdk-proxy/&lt;/code&gt;&lt;/a&gt; — pattern Proxy sur Stripe + Twilio + Anthropic, MIT, prêt à copier.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>ci</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Fifteen lines of Proxy to keep an SDK from breaking my CI</title>
      <dc:creator>Michel Faure </dc:creator>
      <pubDate>Wed, 13 May 2026 09:19:49 +0000</pubDate>
      <link>https://dev.to/michelfaure/fifteen-lines-of-proxy-to-keep-an-sdk-from-breaking-my-ci-5cm2</link>
      <guid>https://dev.to/michelfaure/fifteen-lines-of-proxy-to-keep-an-sdk-from-breaking-my-ci-5cm2</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%2Fsfucmmied0brmj0eliqc.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%2Fsfucmmied0brmj0eliqc.png" alt="Comic strip — Friday evening, Michel merges a Stripe integration, Vercel turns red on "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Friday Vercel refused my merge
&lt;/h2&gt;

&lt;p&gt;Friday April 10th, late afternoon. I merge to &lt;code&gt;main&lt;/code&gt; a Stripe integration that opens a payment webhook endpoint. Vercel pushes the preview build automatically, and three minutes later the icon turns red. I click. Build-time stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: STRIPE_SECRET_KEY missing
    at Object.&amp;lt;anonymous&amp;gt; (/.next/server/chunks/lib_stripe.js:9:11)
    at Module._compile (node:internal/modules/cjs/loader:1376:14)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production works, it has the env var. The preview doesn't have the Stripe secret — I had forgotten to push it into the Vercel preview env. Operator error on my side, fine. But one question remains: why does &lt;code&gt;next build&lt;/code&gt; crash &lt;em&gt;at module load&lt;/em&gt; on a module that's never supposed to run during a static build?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;next build&lt;/code&gt; runs the top level of my modules
&lt;/h2&gt;

&lt;p&gt;The answer fits in one line in the Next.js docs, and it's easy to miss. The Next.js compiler doesn't just transform TypeScript into JavaScript. To analyze API routes, tree-shake, and prepare the serverless runtime, it &lt;strong&gt;runs the top level of every imported module&lt;/strong&gt;. Concretely, my &lt;code&gt;lib/stripe.ts&lt;/code&gt; looked like this at the time:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-25.dahlia&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new Stripe(...)&lt;/code&gt; is an immediately-evaluated expression. The Stripe SDK validates the key in its constructor and &lt;strong&gt;throws if it's &lt;code&gt;undefined&lt;/code&gt;&lt;/strong&gt;. That validation therefore fires during &lt;code&gt;next build&lt;/code&gt;, before any real request exists. My webhook endpoint was never called, but the mere fact that &lt;code&gt;app/api/webhooks/stripe/route.ts&lt;/code&gt; imports &lt;code&gt;lib/stripe.ts&lt;/code&gt; is enough to trigger module execution — and the crash.&lt;/p&gt;

&lt;p&gt;The Stripe SDK is right to validate its key early. The &lt;em&gt;fail-fast&lt;/em&gt; principle (Shore, 2004) says that a system should fail as close as possible to the cause of the error. In production that's exactly what I want: a missing secret should crash on startup, not three days later on a rare call. The problem is that &lt;em&gt;fail fast&lt;/em&gt; becomes &lt;em&gt;fail at build&lt;/em&gt; in an architecture where the build is a strict environment, distinct from the runtime environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap is not Stripe
&lt;/h2&gt;

&lt;p&gt;I dug a bit through the repo after that Friday. The same trap awaits every SDK that validates its credentials in the constructor. The list is longer than you'd think: Twilio, certain official OpenAI and Anthropic clients depending on version, several Google Cloud SDKs, the Brevo client in strict mode. Each has its equivalent of &lt;code&gt;throw new Error('XXX_API_KEY missing')&lt;/code&gt; in the constructor, and each will break your build the same way as soon as you import it from a route Next.js compiles.&lt;/p&gt;

&lt;p&gt;The symptom typically shows up on preview builds. Production has every secret, local dev has a complete &lt;code&gt;.env.local&lt;/code&gt;, but CI and previews carry subsets of env vars depending on team policy. A recent route runs through CI for the first time, and the build falls over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: Proxy plus lazy getter
&lt;/h2&gt;

&lt;p&gt;The fix fits in fifteen lines. The principle: never create the SDK client at the top level. Instead, expose a Proxy object that, on every property access, instantiates the client if needed and delegates. A missing-credentials error surfaces only on the first real API call.&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/stripe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_stripe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStripe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;_stripe&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;_stripe&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;STRIPE_SECRET_KEY&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;key&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_SECRET_KEY missing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;_stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-25.dahlia&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_stripe&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiver&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStripe&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to note in this code. &lt;strong&gt;First&lt;/strong&gt;, the Proxy is exported with the same name and the same type as the previous export — &lt;code&gt;stripe: Stripe&lt;/code&gt;. Every existing caller doing &lt;code&gt;stripe.checkout.sessions.create(...)&lt;/code&gt; keeps working without a single change. That's the main reason to choose Proxy over an exported &lt;code&gt;getStripe()&lt;/code&gt; you'd have to call everywhere: you avoid touching 30 or 40 files that consume the SDK's public API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;, the &lt;code&gt;bind(client)&lt;/code&gt; on methods is necessary because Stripe SDK methods use &lt;code&gt;this&lt;/code&gt; internally. Without &lt;code&gt;bind&lt;/code&gt;, you lose context across the Proxy hop and you get &lt;code&gt;TypeError: Cannot read properties of undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third&lt;/strong&gt;, the &lt;code&gt;_stripe&lt;/code&gt; cache isn't a performance detail — it's a consistency guarantee. Without it, every property access would create a new client, which would break stateful behaviors (the SDK's internal rate limiters, for example) and multiply HTTP keep-alive connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to apply the pattern, and when not to
&lt;/h2&gt;

&lt;p&gt;The pattern pays off whenever an SDK is consumed by a rarely-exercised route — webhooks, admin endpoints, cron jobs that only run via Vercel scheduled — and the secret isn't systematically present in every build environment. That's exactly the Stripe webhook case for me: one caller, one environment (production) with the key.&lt;/p&gt;

&lt;p&gt;Conversely, if the SDK is consumed everywhere in the app and its absence at build means your app cannot function, the Proxy only protects you symbolically. You're just shifting the crash from build to first-render of the first page, which is rarely an improvement. In that case, put the secret everywhere and don't invent a pattern.&lt;/p&gt;

&lt;p&gt;A small middle ground: if the SDK has a &lt;em&gt;dry-run&lt;/em&gt; mode or a mock client, instantiate that client when the secret is missing instead of throwing. It's more surgical, but it assumes the SDK provides the option — and few do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can copy
&lt;/h2&gt;

&lt;p&gt;The code above is fully copyable, modulo the SDK name and the env variable name. Three common adaptations:&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;// Twilio&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;twilio&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;twilio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;twilio&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getClient&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;_client&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;_client&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sid&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;TWILIO_ACCOUNT_SID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;TWILIO_AUTH_TOKEN&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;sid&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TWILIO credentials missing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&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;_client&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;twilioClient&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;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;twilio&lt;/span&gt;&lt;span class="o"&gt;&amp;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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClient&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&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 pattern isn't a revolution, and it isn't new — it's just rarely formulated this way by SDK docs, which push you toward the &lt;code&gt;new Client(...)&lt;/code&gt; top-level that was the right reflex pre-serverless. In the era of compiled builds and multi-env previews, the top-level constructor has become a silent trap, and these fifteen lines neutralize it.&lt;/p&gt;

&lt;p&gt;My question for you: how many top-level SDK imports do you currently have in a module that an API route imports? On Rembrandt I had four — I migrated the other three after the Stripe incident, in anticipation of the day one of their secrets would disappear from a build environment.&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/lazy-sdk-proxy" rel="noopener noreferrer"&gt;&lt;code&gt;rembrandt-samples/lazy-sdk-proxy/&lt;/code&gt;&lt;/a&gt; — lazy-Proxy pattern on Stripe + Twilio + Anthropic SDKs, MIT, copy-pastable.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>ci</category>
      <category>vercel</category>
    </item>
  </channel>
</rss>
