<?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: Marco</title>
    <description>The latest articles on DEV Community by Marco (@mk023).</description>
    <link>https://dev.to/mk023</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3880178%2Fa8175ed4-67ca-4be0-913b-3c52d9be5508.png</url>
      <title>DEV Community: Marco</title>
      <link>https://dev.to/mk023</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mk023"/>
    <language>en</language>
    <item>
      <title>I let every AI crawler in. A month later I cannot tell you how many people read me</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:07:09 +0000</pubDate>
      <link>https://dev.to/mk023/i-let-every-ai-crawler-in-a-month-later-i-cannot-tell-you-how-many-people-read-me-3415</link>
      <guid>https://dev.to/mk023/i-let-every-ai-crawler-in-a-month-later-i-cannot-tell-you-how-many-people-read-me-3415</guid>
      <description>&lt;p&gt;In early August Cloudflare sent me a congratulations email. The site had passed ten thousand pageviews in its first month: 33,561, on a domain that went live on 5 July.&lt;/p&gt;

&lt;p&gt;For about ten seconds it was good news.&lt;/p&gt;

&lt;p&gt;Then I remembered what I had done in July, which was open &lt;code&gt;robots.txt&lt;/code&gt; to every AI crawler in existence. Training included, on purpose. I have no brand to protect and my problem is not that someone copies me: it is that nobody finds me. So everyone gets in.&lt;/p&gt;

&lt;p&gt;Which makes that number, taken as it is, good for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a pageview count not tell you how many people read you?
&lt;/h2&gt;

&lt;p&gt;Because Cloudflare's free plan counts requests, not people, and it does not separate humans from bots. Inside those 33,561 are the crawlers I invited myself, and I have no way to know in what proportion.&lt;/p&gt;

&lt;p&gt;This is a practical problem, not a philosophical one. If a client asks me tomorrow how much traffic the site gets, I do not have an answer I can defend. "Thirty-three thousand" would be a true sentence and false information, because whoever hears it understands "thirty-three thousand people". I would rather say I do not know.&lt;/p&gt;

&lt;p&gt;And there is the opposite question, which matters more to me: is opening the gates working? If GPTBot comes through and re-reads the site every week, I did the right thing. If it never comes, I am giving away bandwidth for nothing, and I would like to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is letting AI crawlers in actually worth it?
&lt;/h2&gt;

&lt;p&gt;On half the question I have a measured answer, and the two halves look nothing alike. Perplexity cites me in first position for "who is Marco Bellingeri, cloud and security engineer". ChatGPT, asked the same thing, does not cite me at all.&lt;/p&gt;

&lt;p&gt;The monitor that measures this runs every Monday and writes the history to a database. So far, routine. The interesting part is what the model did when I asked it that question.&lt;/p&gt;

&lt;p&gt;It did not look for me. It rewrote the question into a search query, "Marco Bellingeri AI security", and read twelve pages about the topic: the European regulation, the AI Act, the Bletchley declaration, a couple of newspapers. None of them was about a person. It answered about the subject because it never found the person.&lt;/p&gt;

&lt;p&gt;That is a different kind of failure from "your content is not good enough". It is the engine failing to connect the question to the page, and the difference changes what is worth doing next. If the crawler never comes, rewriting articles is wasted time and the problem is access. If it comes and does not cite, the problem is that the content is not extractable, and then rewriting is the work.&lt;/p&gt;

&lt;p&gt;To know which, I needed to count who comes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you count crawlers without tracking people?
&lt;/h2&gt;

&lt;p&gt;By looking at the &lt;code&gt;User-Agent&lt;/code&gt; of every page request and recording only the family it belongs to, with no IP address, no cookie, no session, nothing that ties two requests to the same person.&lt;/p&gt;

&lt;p&gt;The site is static and runs on Cloudflare Workers, so the Worker was the obvious place. I started from an assumption that turned out to be false: I thought every request went through it. It did not. The Worker was configured to run on five routes only, the root and four APIs. Every page of the site was served straight from static assets, and my code never saw a thing.&lt;/p&gt;

&lt;p&gt;The fix was not "send everything through". Requests to static assets are free and unlimited; requests that invoke the Worker consume the free plan's quota of a hundred thousand a day. Sending everything through would have put my code in front of every font and every stylesheet, which is most of the volume and all of the latency that matters, in order to count things that are not pageviews.&lt;/p&gt;

&lt;p&gt;So only HTML goes through. A pageview &lt;em&gt;is&lt;/em&gt; an HTML request. The rest is what that page drags along with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NOT to keep, which is the hard part
&lt;/h2&gt;

&lt;p&gt;The classification is trivial: a list of names, GPTBot, ClaudeBot, PerplexityBot, Googlebot, and a couple of rules to spot a real browser. Twenty lines. What took me time was deciding what to throw away.&lt;/p&gt;

&lt;p&gt;A browser's &lt;code&gt;User-Agent&lt;/code&gt; identifies a person far more than it looks. Combined with other signals it becomes a fingerprint. So for a person I keep one word, &lt;code&gt;human&lt;/code&gt;, and drop the string.&lt;/p&gt;

&lt;p&gt;For a crawler I already know I keep the family name and nothing else: I know who it is, the rest adds nothing.&lt;/p&gt;

&lt;p&gt;That leaves the awkward case, the one that made me rewrite the code twice. If I do not recognise the client, what do I do? The first version called it a bot and kept its full &lt;code&gt;User-Agent&lt;/code&gt;. Wrong on two counts. The first is that a person can be in there: a text browser, a webview inside an app, an accessibility tool, a &lt;code&gt;User-Agent&lt;/code&gt; stripped by a privacy extension. Treating them as a bot and keeping their string is exactly the thing I said I would not do.&lt;/p&gt;

&lt;p&gt;The second reason is that the number lies. If I put "a crawler I do not recognise" and "I could not tell what this was" in the same bucket, I then read that forty per cent of the traffic is automated and believe it, when part of that forty is my own uncertainty wearing a certainty's clothes. And that number is the one that ends up in front of a client.&lt;/p&gt;

&lt;p&gt;Now there are two labels. A client that declares itself automated in its own &lt;code&gt;User-Agent&lt;/code&gt;, by writing &lt;code&gt;bot&lt;/code&gt; or &lt;code&gt;crawler&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt; into it, gets kept as its short product name: that is what lets me notice a new family. A client that simply does not place gets recorded as &lt;code&gt;unclassified&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does counting cost?
&lt;/h2&gt;

&lt;p&gt;Little, but not nothing, and the point is knowing beforehand rather than finding out. Every page that goes through the Worker consumes one request of the free quota. At today's traffic that is around eleven hundred a day against a hundred thousand: a margin of about ninety, which is comfortable but not infinite, and there is no rate limit on those routes.&lt;/p&gt;

&lt;p&gt;I wrote it into the project README, along with what I will do if the number gets close, in order: watch the counter, narrow the routes and accept counting less, and only then pay. A declared ceiling with an exit path next to it is acceptable debt. A ceiling discovered by accident one night is an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that nearly made me publish a lie
&lt;/h2&gt;

&lt;p&gt;My site's privacy policy said, in Italian and in English, that it "uses no tracking or analytics".&lt;/p&gt;

&lt;p&gt;That was true until the day before. If I had shipped the counter without touching that page, the site that sells transparency would have published a false statement about itself, and no test would have caught it: tests look at code, not at promises.&lt;/p&gt;

&lt;p&gt;I rewrote it before shipping. It now says what it does: it counts requests one at a time, with no identifiers, no profiling, and keeps the data for three months because that is when the platform deletes it. I also removed the word "aggregate" from a first draft, because it was not true: what I write is one event per request, not a running total, and calling it aggregate would have been more reassuring and less accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still do not know
&lt;/h2&gt;

&lt;p&gt;The counter has been on for a few hours. I generated the first data points myself, sending three test requests with fake &lt;code&gt;User-Agent&lt;/code&gt; strings to check that it worked, and honestly I could not tell them apart from real traffic.&lt;/p&gt;

&lt;p&gt;So I have nothing to tell you about crawlers yet. In a week I will know: which families come through, how often, on which pages, and above all whether GPTBot shows up at all. That answer decides whether it is worth continuing to write to be found by a model, or whether I have been writing for an audience that never came.&lt;/p&gt;

&lt;p&gt;In the meantime I have lost the ability to say thirty-three thousand. It looks like a step backwards and it is a real step forward: before, I had a big number and no idea what was inside it. Now I have a measurement on its way, and in the meantime an honest answer.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>analytics</category>
      <category>seo</category>
      <category>web</category>
    </item>
    <item>
      <title>A permissive robots.txt is not a licence</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:24:23 +0000</pubDate>
      <link>https://dev.to/mk023/a-permissive-robotstxt-is-not-a-licence-2m3i</link>
      <guid>https://dev.to/mk023/a-permissive-robotstxt-is-not-a-licence-2m3i</guid>
      <description>&lt;p&gt;I have a scraper that watches ten sites I consider adjacent to my work. It has been running since early July. The output goes into a vector store that nothing currently reads, which is the only reason this story ends without a lawyer in it.&lt;/p&gt;

&lt;p&gt;I audited those ten sources this week, against the same bar I use for the security feeds on my site: the licence has to permit commercial reuse in writing. My site sells things. Ambiguous means no.&lt;/p&gt;

&lt;p&gt;Two passed. Eight didn't. And the pattern in the failures was not the one I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two questions are not the same question
&lt;/h2&gt;

&lt;p&gt;Here is Simon Willison's robots.txt, which is about as welcoming as they come:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight robot_framework"&gt;&lt;code&gt;User-agent: ChatGPT-User&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Disallow:&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;User-agent:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Disallow:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/admin/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Disallow:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/search/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An explicit allow for an AI user agent. No GPTBot block, no Google-Extended block, nothing. My scraper is welcome.&lt;/p&gt;

&lt;p&gt;Now the footer of that same site: a copyright symbol and a list of years. No licence. No terms page. Nothing that grants me the right to republish a line of it on a page that sells consulting.&lt;/p&gt;

&lt;p&gt;And here is Troy Hunt's footer, on a site whose robots.txt blocks nothing interesting either:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Copyright 2026, Troy Hunt. This work is licensed under a Creative Commons Attribution 4.0 International License. In other words, share generously but provide attribution.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a licence. It says what I may do and what I owe in return. Under my own admission rule, Troy Hunt is in and Simon Willison is out, and it has nothing to do with which of them is friendlier to crawlers.&lt;/p&gt;

&lt;p&gt;The two questions are separate, and only one of them is the one I need answered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;robots.txt answers "may your bot fetch this page?"&lt;/li&gt;
&lt;li&gt;the licence answers "may you republish what your bot fetched?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A site can say yes to the first and stay silent on the second. Silence is not a yes. It is the absence of one, which under my rule is a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both of them, in both directions
&lt;/h2&gt;

&lt;p&gt;The inverse showed up too. Julia Evans' robots.txt contains, in ASCII art large enough to be unmissable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NO LLM PLZ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus a &lt;code&gt;Disallow: /&lt;/code&gt; for GPTBot. It could not be clearer. What it is not, technically, is a licence restriction: &lt;code&gt;/license&lt;/code&gt;, &lt;code&gt;/licence&lt;/code&gt;, &lt;code&gt;/copyright&lt;/code&gt; and &lt;code&gt;/terms&lt;/code&gt; all 404, and the footer says only "© Julia Evans."&lt;/p&gt;

&lt;p&gt;So a lawyer might tell me that robots.txt is not a contract, and that the absence of an explicit prohibition leaves me room. I don't want that room. A person wrote NO LLM PLZ in ASCII on their own website. That is the clearest possible statement of intent, and building a business on top of the gap between what someone stated and what they happened to make enforceable is a bad way to run a business that sells trust.&lt;/p&gt;

&lt;p&gt;I gave that verdict its own name. Not "out" for licensing reasons, but out by intent. It documents that we read the wish and honoured it, rather than losing the distinction in a spreadsheet where every rejection looks the same.&lt;/p&gt;

&lt;p&gt;The Pragmatic Engineer's newsletter turned out to be the most precise of all of them, with a header I hadn't seen in the wild before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Content-Signal&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; search=yes, ai-input=yes, ai-train=no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieval yes, training no. That is a genuinely useful signal, and I am glad someone is putting it in machine-readable form. It still isn't a reuse licence, so the source is out for me, but for once I knew exactly what the author wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure isn't the audit, it's when I ran it
&lt;/h2&gt;

&lt;p&gt;Eight sources out of ten failed, and I could tell you that finding it is the win. It isn't. The scraper ran for a month against sites that had said no in writing, and the only reason nothing was published is that the retrieval path on top of that store doesn't exist yet.&lt;/p&gt;

&lt;p&gt;That is luck, not a control.&lt;/p&gt;

&lt;p&gt;The licence gate belongs at the moment a source is admitted. Not at the moment its content is first served, which is where I had implicitly put it by thinking of the whole thing as "internal, for now". Internal is a property of today's architecture. The scraping happened anyway.&lt;/p&gt;

&lt;p&gt;Two things went into the repo alongside the deletions. The verdicts, each with a verbatim quote and a link to the page I read it on, in the same compliance file I already keep for the site's public feeds. And a note, in plain sight, that the public feeds have a CI test which fails when a source has no written licence, while the internal roster has no such test, because it lives in a database and my test suite runs without network. That gate is a human reading a file. It is a declared ceiling, not a control, and writing it down as such is the difference between a limitation and a surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Check both, in both directions.&lt;/p&gt;

&lt;p&gt;A crawler-friendly robots.txt with no licence gives you access and no rights. A hostile robots.txt on a CC BY site gives you rights you probably shouldn't take. The combination you want is a written grant, and the only way to find it is to open the licence page yourself.&lt;/p&gt;

&lt;p&gt;The scraper never had to ask. That was the whole problem.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>data</category>
      <category>llm</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>My quality gate wasn't strict. It was dead.</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:59:58 +0000</pubDate>
      <link>https://dev.to/mk023/my-quality-gate-wasnt-strict-it-was-dead-14lk</link>
      <guid>https://dev.to/mk023/my-quality-gate-wasnt-strict-it-was-dead-14lk</guid>
      <description>&lt;p&gt;There is a job in my CI called &lt;code&gt;judge&lt;/code&gt;. It reads the monthly issue my content pipeline writes, scores it against a rubric with five criteria, and blocks the merge if anything comes back at 2 out of 5 or lower. An LLM grading an LLM, with a written policy about what counts as broken.&lt;/p&gt;

&lt;p&gt;It went red on a content PR this week. My first thought was that the piece was weak. My second thought, about four minutes later, was worse: this gate had been red on every content PR for a while, and I had been reading that as a high bar.&lt;/p&gt;

&lt;p&gt;It wasn't a high bar. The job never reached the rubric at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: anthropic messages -&amp;gt; 400: output_config.format.schema:
For 'integer' type, properties maximum, minimum are not supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The judge asks the model for structured output, and the schema it sends had this in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;voto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;integer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is correct JSON Schema, and which the structured output layer rejects. The request never made it to the model. The job exited 1 before scoring a single word.&lt;/p&gt;

&lt;p&gt;A gate that cannot go green is not enforcing anything. It has the same effect on your pipeline as &lt;code&gt;continue-on-error&lt;/code&gt;, except it also makes you feel virtuous while it does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error message named half the problem
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to delete &lt;code&gt;minimum&lt;/code&gt; and &lt;code&gt;maximum&lt;/code&gt; and move on. I nearly did. What stopped me was noticing that the schema had a second kind of constraint in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;motivo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Length constraints are rejected too. &lt;code&gt;maxLength&lt;/code&gt;, &lt;code&gt;minLength&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt;, &lt;code&gt;minItems&lt;/code&gt;, all of it. My schema had three of them, and the 400 mentioned none, because validation stops at the first failure. If I had fixed what the message named, I would have pushed, waited for CI, and collected the next 400. Then the one after that.&lt;/p&gt;

&lt;p&gt;This is a general property of error messages, not a quirk of one API: they report the first thing that broke, not the set of things that are broken. The fix that only addresses what the message says is a fix sized to the message, not to the defect.&lt;/p&gt;

&lt;p&gt;The other thing worth knowing here: the Python and TypeScript SDKs strip unsupported constraints for you before the request goes out. My pipeline is zero-dependency and calls the API with native &lt;code&gt;fetch&lt;/code&gt;, so nobody was stripping anything. If your SDK has been quietly saving you, you will find out the day you drop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the test is about the contract
&lt;/h2&gt;

&lt;p&gt;I could have written a test asserting no &lt;code&gt;minimum&lt;/code&gt; on integers. It would have passed, and it would have been useless three weeks later when someone adds a &lt;code&gt;pattern&lt;/code&gt; to a string.&lt;/p&gt;

&lt;p&gt;Instead the accepted and rejected keywords are written down once, and every schema I send to the API gets checked against them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RIFIUTATE&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;Map&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="s2"&gt;minimum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric constraint&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maximum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric constraint&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maxLength&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;length constraint&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pattern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string constraint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keywordRifiutate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$&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="cm"&gt;/* walks the tree */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks nested schemas, and it knows that inside &lt;code&gt;properties&lt;/code&gt; the keys are my field names rather than schema keywords, so a field genuinely called &lt;code&gt;pattern&lt;/code&gt; doesn't trip it.&lt;/p&gt;

&lt;p&gt;On first run it found sixteen violations across the judge schema. Not two. My generation schema, checked at the same time, was already clean, which I would not have bet on.&lt;/p&gt;

&lt;p&gt;There is a second check next to it that lists keywords the contract doesn't mention. It fails rather than allowing them, which sounds aggressive for something the API might accept fine. The reason is that I want the moment where somebody adds an unfamiliar keyword to be the moment somebody reads the docs, instead of the moment CI goes red for a reason nobody connects to the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the constraint went
&lt;/h2&gt;

&lt;p&gt;The rubric still runs on a 1 to 5 scale. That range now lives in the two places the API can't reject it.&lt;/p&gt;

&lt;p&gt;The prompt describes it, with each score defined. And the code treats an out-of-range score the way it already treats a missing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fuoriScala&lt;/span&gt; &lt;span class="o"&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail closed. If the model returns a 7, the rubric is unreadable, and an unreadable rubric doesn't promote anything. That was already the policy for a criterion the model forgot to fill in. A score outside the scale is the same class of problem, so it gets the same answer.&lt;/p&gt;

&lt;p&gt;Moving a constraint out of a schema usually means giving it up. Here it just meant enforcing it somewhere with a worse reputation and better reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check on your own pipeline
&lt;/h2&gt;

&lt;p&gt;Grep your CI history for a job that has never been green. Not "was red today", never green at all, or not since some commit that had nothing to do with it. That job is not protecting you, and it is currently costing you the specific kind of comfort that comes from thinking it is.&lt;/p&gt;

&lt;p&gt;Mine ran for weeks. It was the most reassuring line in the workflow file.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ci</category>
      <category>llm</category>
      <category>testing</category>
    </item>
    <item>
      <title>From text-JSON parsing to Claude tool use in JobSearch</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Tue, 21 Jul 2026 00:55:05 +0000</pubDate>
      <link>https://dev.to/mk023/from-text-json-parsing-to-claude-tool-use-in-jobsearch-3cjg</link>
      <guid>https://dev.to/mk023/from-text-json-parsing-to-claude-tool-use-in-jobsearch-3cjg</guid>
      <description>&lt;p&gt;My job-search tool had five functions whose only purpose was fixing JSON that Claude had just written. &lt;code&gt;_clean_json_text&lt;/code&gt;, &lt;code&gt;_fix_unescaped_newlines&lt;/code&gt;, &lt;code&gt;_fix_single_quotes&lt;/code&gt;, &lt;code&gt;_strip_markdown_wrapper&lt;/code&gt;, &lt;code&gt;_extract_and_parse_json&lt;/code&gt;. There was also a sixth, &lt;code&gt;_retry_json_fix&lt;/code&gt;, which took the broken JSON and sent it back to the model with a polite request to fix its own mess. I wrote every one of them, one bug at a time, over weeks. I was a little proud of them.&lt;/p&gt;

&lt;p&gt;That was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you end up with five parsers
&lt;/h2&gt;

&lt;p&gt;JobSearch is my personal tool, in production, single user: me. It ingests job offers from nine boards, and when I press "Analyze", Claude reads the offer against my CV and returns a structured verdict: score, recommendation, career track, the English level the ad actually requires. That verdict has to be JSON, because everything downstream is a database row, not prose.&lt;/p&gt;

&lt;p&gt;The first version did what every tutorial does. Ask the model for JSON in the prompt, take &lt;code&gt;response.content[0].text&lt;/code&gt;, run &lt;code&gt;json.loads&lt;/code&gt; on it. It worked in the demo and then production started teaching me things.&lt;/p&gt;

&lt;p&gt;The model wrapped the JSON in markdown fences, so I wrote a function to strip them. Sometimes it used single quotes, so I wrote a function to fix those. Then a description with a line break inside a string, so I wrote &lt;code&gt;_fix_unescaped_newlines&lt;/code&gt;. Then a &lt;code&gt;NaN&lt;/code&gt; where a number should be. Every fix was five lines, obviously correct, and came with its own tests. I still have the test names in the git history and they read like a confession: &lt;code&gt;test_removes_trailing_commas&lt;/code&gt;, &lt;code&gt;test_replaces_nan_with_null&lt;/code&gt;, &lt;code&gt;test_replaces_infinity&lt;/code&gt;, &lt;code&gt;test_unclosed_fence_still_strips_opening&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;By April the parsing layer was around 250 lines with seven strategies, chained, each catching what the previous one let through. The last resort was the AI self-repair call: if nothing parsed, send the broken output back and ask the model to repair it. A second API call, with real latency and real cost, to fix a formatting problem the first call should never have had.&lt;/p&gt;

&lt;p&gt;I had a test suite asserting that my code could survive output nobody should ever have produced. That is not robustness. That is a bug report addressed to the wrong recipient.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get guaranteed JSON from Claude with tool_choice instead of parsing text?
&lt;/h2&gt;

&lt;p&gt;You define exactly one tool whose input schema is the shape of the answer you want, and you force it with &lt;code&gt;tool_choice&lt;/code&gt;. The model cannot answer any other way, and the JSON arrives already parsed and validated against the schema by the API, not by your code.&lt;/p&gt;

&lt;p&gt;Anthropic's API has tool use, and you normally reach for it to let the model call your functions. This is the stricter reading: one tool, forced, and the answer comes back as a Python dict on the response object.&lt;/p&gt;

&lt;p&gt;I already had a Pydantic model for the analysis, because the DB row needed one. So the schema was free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_schema_from_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_cls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Produce a JSON Schema from a Pydantic model, suitable for input_schema.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submit_analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return the structured job analysis.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_schema_from_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JobAnalysis&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submit_analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;  &lt;span class="c1"&gt;# a dict, parsed by the SDK, no text in sight
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refactor landed on April 14 as one commit: every AI call in the codebase migrated, all five parsers deleted, the self-repair fallback deleted, the garbage-JSON test file deleted with them. The commit message says minus 200 lines and it undersells it, because the lines that left were the ones I had to re-read every time something broke.&lt;/p&gt;

&lt;p&gt;It was the scary PR of the batch. I shipped it in the middle of an afternoon where Claude and I pushed thirteen PRs to production, and I wrote about that day &lt;a href="https://marcobellingeri.dev/en/writing/thirteen-prs-one-afternoon" rel="noopener noreferrer"&gt;separately&lt;/a&gt;. Twelve of those PRs were routine. This one deleted a safety net and replaced it with a promise from an API, in the same diff.&lt;/p&gt;

&lt;p&gt;One thing I kept: the Pydantic validation after the call. The schema guarantees shape, not sense. A score of 950 on a 0-100 field is schema-valid JSON and still garbage, and model output stays untrusted input no matter how it is delivered. The contract moved into the API; the checking stayed on my side of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise came after launch
&lt;/h2&gt;

&lt;p&gt;Here is the part I did not expect, and the reason this piece is not just "use tool use, delete your parsers".&lt;/p&gt;

&lt;p&gt;With text output, the model treated my prompt rules a bit loosely, and the parsing chaos hid it. With a forced schema, it obeys much more literally. I had a fallback rule for freelance positions: Italian job ads sometimes want a P.IVA, a VAT number, which changes whether the offer makes sense for me at all. The rule said, roughly, "if freelance status is ambiguous, flag it". Under tool use the model started flagging offers that mentioned freelancing in passing, a line about contractors in another team, anything. Ambiguous had quietly meant "mentioned anywhere".&lt;/p&gt;

&lt;p&gt;The schema made the model more obedient, and the obedience exposed how sloppy my instructions had been. The fix was not code. It was rewriting the prompt with explicit precedence: the contract type stated in the offer wins, the fallback fires only when the offer itself is about the contract and does not settle it.&lt;/p&gt;

&lt;p&gt;So the lesson I actually paid for: when you migrate from text parsing to tool use, your interpretive prompt rules need to be tightened, not loosened. The model stops improvising on format and starts taking your words seriously. If your rules were vague, you find out now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;The parsers were never defensive programming. They were a symptom that the contract lived on the wrong side of the API call, and every new repair function was me renegotiating that contract in the worst possible place, after the response, one edge case at a time.&lt;/p&gt;

&lt;p&gt;If your pipeline has a function called &lt;code&gt;_fix_single_quotes&lt;/code&gt;, you do not need a better parser. Move the shape into &lt;code&gt;input_schema&lt;/code&gt;, force the tool, keep your validation, and delete the museum. Then go re-read your prompt, because the model is about to start believing every word of it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>programming</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>I made my portfolio site audit its own security headers, live, in front of you</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:13:30 +0000</pubDate>
      <link>https://dev.to/mk023/i-made-my-portfolio-site-audit-its-own-security-headers-live-in-front-of-you-11pf</link>
      <guid>https://dev.to/mk023/i-made-my-portfolio-site-audit-its-own-security-headers-live-in-front-of-you-11pf</guid>
      <description>&lt;p&gt;My portfolio has a section that runs &lt;code&gt;curl -I&lt;/code&gt; on itself while you watch. Not a screenshot of headers I pasted in last year and forgot to update. The page fetches its own URL, reads the response headers off it, and prints each one ON or missing, right there in the section. If I ever ship a build that drops a header, the page snitches on me the next time someone loads it.&lt;/p&gt;

&lt;p&gt;I want to walk through how it works, and then the part that made me rewrite it: the real Content-Security-Policy is not in one place. It is split across two. Showing only the header would have told half the truth, on a page whose whole point is not doing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The site is built with Astro, output fully static, served from Cloudflare. There is a section on the page titled like a shell command. Under it, a list of the security headers I expect the edge to send. The expected list is not hardcoded into the script, it comes from the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const expected = [
  'Content-Security-Policy',
  'Strict-Transport-Security',
  'X-Content-Type-Options',
  'Referrer-Policy',
  'Permissions-Policy',
];
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same origin, so the browser is allowed to read the response headers back. That is the whole trick. A &lt;code&gt;fetch&lt;/code&gt; to any other site would get its headers hidden by CORS, but a page is allowed to look at itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can a website audit its own security headers live, in the page itself?
&lt;/h2&gt;

&lt;p&gt;The page runs a HEAD &lt;code&gt;fetch&lt;/code&gt; against its own URL. Same origin, so the browser lets it read the response headers back, and the card prints which ones are there and which are missing while you watch.&lt;/p&gt;

&lt;p&gt;The client script, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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;name&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
    &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Security-Policy (meta)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metaCsp&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;foldHashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metaCsp&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;HEAD, not GET, because I only care about the headers and there is no reason to pull the body again. For each expected header it calls &lt;code&gt;res.headers.get(name)&lt;/code&gt;. If the value is there, the row renders ON and prints it. If it is null, the row goes missing and gets a different style. No allowlist of "good" values, no grading. It shows what came back.&lt;/p&gt;

&lt;p&gt;One detail I care about more than it probably deserves: every cell is built with &lt;code&gt;document.createElement&lt;/code&gt; and &lt;code&gt;textContent&lt;/code&gt;, never &lt;code&gt;innerHTML&lt;/code&gt;. This is a section about security headers. If I XSS my own security section by piping a header value straight into the DOM as HTML, I have earned every bit of the embarrassment. So the header values are text, and text only.&lt;/p&gt;

&lt;p&gt;The site also scored A+ on Mozilla's HTTP Observatory, and the hero links straight to that scan so you can re-run it yourself instead of taking my word for the badge. The live card and the external scanner are checking the same thing from two sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a hash-based CSP have to live in a meta tag instead of the response headers?
&lt;/h2&gt;

&lt;p&gt;Because only the build knows the hashes, and the headers file is static. Astro computes the &lt;code&gt;sha256-&lt;/code&gt; values at build time and writes them into a &lt;code&gt;&amp;lt;meta http-equiv&amp;gt;&lt;/code&gt;; if the same policy also sat in the headers, the browser would apply both as an intersection, and a &lt;code&gt;script-src 'self'&lt;/code&gt; written there would cancel out the hashes in the meta.&lt;/p&gt;

&lt;p&gt;This is where I had to go back and change the card. When I first wrote it, it read the five headers off the response and stopped. Clean, done. Except the meaningful part of my policy, the &lt;code&gt;script-src&lt;/code&gt; full of &lt;code&gt;sha256-&lt;/code&gt; hashes, lives in the HTML, not in the header the card was reading.&lt;/p&gt;

&lt;p&gt;Why not just put the whole CSP in the Cloudflare &lt;code&gt;_headers&lt;/code&gt; file and be done? Because then the browser applies both policies as an intersection, and they fight. My &lt;code&gt;_headers&lt;/code&gt; file says exactly this, in a comment I left for future me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# La CSP non sta qui: la genera Astro (`security.csp` in astro.config.mjs) come
# &amp;lt;meta http-equiv&amp;gt;, perché solo in build può calcolare gli hash dei propri script.
# Se una CSP vivesse anche qui, le due policy verrebbero applicate entrambe come
# intersezione: un `script-src 'self'` in questo file annullerebbe gli hash del meta
# e rimetterebbe il sito offline.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;script-src 'self'&lt;/code&gt; in the header would intersect with the hash-based &lt;code&gt;script-src&lt;/code&gt; in the meta, and the result blocks the very scripts the hashes were meant to allow. Site offline.&lt;/p&gt;

&lt;p&gt;So there is exactly one CSP directive that has to live in the header, and it is the one a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag is not allowed to express: &lt;code&gt;frame-ancestors&lt;/code&gt;. Per spec, &lt;code&gt;frame-ancestors&lt;/code&gt; inside a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; is ignored, so it has to be a real response header. That is the whole content of the CSP header at the edge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: frame-ancestors 'none'
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means my original card, reading only headers, would have proudly printed a Content-Security-Policy of &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; and called it a day. Technically true. Also a lie by omission, because it hid the part of the policy that does most of the work.&lt;/p&gt;

&lt;p&gt;So the card reads the meta too. It pulls the &lt;code&gt;&amp;lt;meta http-equiv="content-security-policy"&amp;gt;&lt;/code&gt; content out of the DOM and prints it as a second CSP row. The hash list is dozens of entries long and useless to look at, so it folds each run of hashes into a count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;foldHashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;'sha&lt;/span&gt;&lt;span class="se"&gt;\d{3}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+'&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'sha&lt;/span&gt;&lt;span class="se"&gt;\d{3}&lt;/span&gt;&lt;span class="sr"&gt;-/g&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; hash `&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 count is read from the real policy on the page, not a number I typed. If Astro emits one more inline style tomorrow, the number goes up on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cost me, honestly
&lt;/h2&gt;

&lt;p&gt;I am mid-level. I did not know going in that &lt;code&gt;frame-ancestors&lt;/code&gt; was meta-blind, or that two CSPs intersect instead of one winning. I learned both by breaking the site. There is a manual hash in my Astro config for one inline script, the anti-flash theme script that runs before first paint, because Astro leaves &lt;code&gt;is:inline&lt;/code&gt; scripts alone and will not hash them for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;scriptDirective&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resources&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="s2"&gt;'self'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://challenges.cloudflare.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;hashes&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;sha256-WV81hIAeXjEdgj/cFIXtOf53g8pIquCjmXQuCHOehlw=&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;If I touch that script and forget the hash, &lt;code&gt;npm run test:csp&lt;/code&gt; fails and tells me the new hash to paste. That test exists because I shipped the mismatch once and the theme script got blocked in production.&lt;/p&gt;

&lt;p&gt;The card is not clever. It is a HEAD request, five &lt;code&gt;.get()&lt;/code&gt; calls, and one &lt;code&gt;querySelector&lt;/code&gt;. What I like about it is that it cannot drift. A README claiming "we set strict security headers" ages the moment someone edits a config. This section re-derives the claim from the live response every time the page loads, and it reads both halves of a policy that lives in two files, because reading one half would make the page a small liar about the one topic it is supposedly honest about.&lt;/p&gt;

&lt;p&gt;If you want to try the pattern: same-origin HEAD, read the headers, read the meta CSP too if you have one, build the DOM with &lt;code&gt;textContent&lt;/code&gt;. That is the entire thing.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>astro</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Thirteen PRs in one afternoon, and the boring rule that made it possible</title>
      <dc:creator>Marco</dc:creator>
      <pubDate>Wed, 15 Apr 2026 10:05:39 +0000</pubDate>
      <link>https://dev.to/mk023/how-i-shipped-13-prs-in-one-afternoon-pair-programming-with-claude-and-what-i-learned-1274</link>
      <guid>https://dev.to/mk023/how-i-shipped-13-prs-in-one-afternoon-pair-programming-with-claude-and-what-i-learned-1274</guid>
      <description>&lt;p&gt;I sat down one afternoon with a list of six things I wanted to ship to JobSearch, the job hunting tool I wrote for my own job hunt and then kept running in production for exactly one user. Three small features, two pieces of tech debt, and one refactor I had been avoiding for a month because it deleted a safety net and replaced it with a promise.&lt;/p&gt;

&lt;p&gt;I expected to finish two of them. Maybe three if the afternoon went well.&lt;/p&gt;

&lt;p&gt;Six hours later thirteen pull requests were merged into &lt;code&gt;main&lt;/code&gt;, every one of them green, every one of them live. I have gone back over that day more than once since, because the interesting part is not the number. Thirteen is a number I got by accident. The part worth writing down is the rule I followed without thinking about it, which is the only reason the number did not turn into an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually on the table
&lt;/h2&gt;

&lt;p&gt;JobSearch is a FastAPI application with PostgreSQL and Redis behind it, deployed on Render. Nothing exotic. At the time it had 394 tests and a CI pipeline with nine stages: linting, formatting, a security scanner, a dependency audit, stylelint, CodeQL, the test suite, a Docker build, and the deploy. It is a real production app with a user base of one, and that one user files very detailed bug reports.&lt;/p&gt;

&lt;p&gt;I started by asking Claude to plan the work rather than do it. What came back was a six PR roadmap ordered by risk, cheapest and safest first, with the refactor I was scared of sitting last. I changed two things about the order and then we just started walking down it. That planning step is the only reason the afternoon had a shape at all. Without it I would have opened the scary one first, because that is what I always do.&lt;/p&gt;

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

&lt;p&gt;Every single PR went the same way, with no exceptions and no shortcuts when I got tired:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Branch from the latest &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One concern per branch. Never a bug fix riding along with a feature.&lt;/li&gt;
&lt;li&gt;Tests written for the change.&lt;/li&gt;
&lt;li&gt;Push, wait for nine green checks, merge, delete the branch.&lt;/li&gt;
&lt;li&gt;Pull &lt;code&gt;main&lt;/code&gt;, then start the next one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Written down it looks like the first page of any guide to working with git, and I know how unremarkable it sounds. It is also the whole answer. No two branches were ever open at the same time, so no two changes ever had to be reasoned about together, so no merge ever produced a surprise. When something did go wrong, and it did, the surface area of what could have caused it was one small diff, not six overlapping ones.&lt;/p&gt;

&lt;p&gt;We never rolled anything back. Production never broke. Not because we were careful in some heroic way, but because at any moment there was only one thing in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who did what
&lt;/h2&gt;

&lt;p&gt;Claude did the typing and most of the verification. I did the decisions and the order.&lt;/p&gt;

&lt;p&gt;That split sounds like a slogan, so here is what it looked like in practice. On two of the thirteen PRs I threw away the first proposal, not because the code was wrong but because it had quietly grown: I asked for a fix and got the fix plus a small refactor of the module around it plus a new helper that nothing else would ever call. Both times the second attempt was a third of the size and did exactly what I asked. Scope creep is the failure mode I watch for now. It arrives helpfully.&lt;/p&gt;

&lt;p&gt;I also read every diff before merging. All thirteen. This is the part people skip when they describe pair programming with a model, and it is the part that makes the rest of it safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim I stopped believing
&lt;/h2&gt;

&lt;p&gt;Twice that afternoon Claude told me the test suite was passing, and twice it was not.&lt;/p&gt;

&lt;p&gt;Both times the mechanism was the same. A slow test had been skipped locally, the summary line said everything else was green, and the report I got was true about what had run and silent about what had not. Running &lt;code&gt;pytest&lt;/code&gt; myself took ten seconds. Believing the summary would have cost a full CI cycle each time, and worse, it would have taught me that the summary is worth believing.&lt;/p&gt;

&lt;p&gt;That is now a habit rather than a rule: when the report is about whether we are allowed to proceed, I check the report myself. Not out of suspicion. It is just that a claim about the state of the world and the state of the world are two different objects, and only one of them blocks a merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I was afraid of
&lt;/h2&gt;

&lt;p&gt;The last PR on the list deleted about 250 lines of code whose only job was repairing JSON that Claude had just written, and replaced the whole thing with Anthropic's tool use and a forced schema. Net delta across the client and its tests was around 370 lines removed.&lt;/p&gt;

&lt;p&gt;It went out alone, last, behind its own flag, with nothing else in flight. And it introduced a bug that took until the next morning to notice, because the bug was not in the code. It was in a prompt rule that had been surviving on ambiguity. I wrote that one up separately, in &lt;a href="https://marcobellingeri.dev/en/writing/tool-use-jobsearch" rel="noopener noreferrer"&gt;from parsing JSON in the text to Claude's tool use&lt;/a&gt;, because it deserves its own piece.&lt;/p&gt;

&lt;p&gt;The relevant bit here is that the riskiest change was the easiest one to diagnose the next day. One PR, one concern, one diff to reread. If it had shipped in the middle of the pile with four other things, I would have spent that morning bisecting instead of thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;Plan before you execute, even for an afternoon. Especially for an afternoon, because that is when the temptation to just start is strongest.&lt;/p&gt;

&lt;p&gt;Keep the reviews. The model writes faster than you read, and that gap is where the mistakes live.&lt;/p&gt;

&lt;p&gt;And keep the boring rule. One branch, one concern, wait for green. It is not a productivity technique and it does not feel fast. It is what let me work for six hours at that pace without ever being in a position where I could not explain what was running in production.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>claude</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
