<?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: Pierre- Laurent Medori</title>
    <description>The latest articles on DEV Community by Pierre- Laurent Medori (@pierrelaurentmedori).</description>
    <link>https://dev.to/pierrelaurentmedori</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%2F4022967%2F06fd4098-6c66-4406-b44d-43d0e63b179f.png</url>
      <title>DEV Community: Pierre- Laurent Medori</title>
      <link>https://dev.to/pierrelaurentmedori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pierrelaurentmedori"/>
    <language>en</language>
    <item>
      <title>What does a JWT actually prove? Less than your API assumes</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:12:00 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/what-does-a-jwt-actually-prove-less-than-your-api-assumes-1020</link>
      <guid>https://dev.to/pierrelaurentmedori/what-does-a-jwt-actually-prove-less-than-your-api-assumes-1020</guid>
      <description>&lt;p&gt;View source on one of our generated widgets and you can find a tenant id, a connector handle and the address of a token issuer. Take those values to a terminal and you can obtain a valid token. Nobody has stolen a signing key. The issuer is doing what it was built to do.&lt;/p&gt;

&lt;p&gt;I run backend engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, where we are testing an AI extension builder. Its widgets call third-party APIs through a proxy that holds the real API keys. The feature is a prototype serving a handful of pilot tenants; the design below is still evolving.&lt;/p&gt;

&lt;p&gt;The token tells the proxy which tenant the issuer accepted a request for. It does not identify the person making the call. That distinction determines what we can safely authorize, and it leaves a residual risk that a signature cannot remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a bearer token proves
&lt;/h2&gt;

&lt;p&gt;A bearer token grants its permissions to whoever possesses it. Its use does not require a separate proof that the presenter holds a cryptographic key. That is the model described by &lt;a href="https://www.rfc-editor.org/rfc/rfc6750#section-1.2" rel="noopener noreferrer"&gt;RFC 6750, section 1.2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We use signed JWTs. Verifying one against a trusted issuer's key establishes that the signed claims have not been altered. The consumer must also check the claims required by its protocol, including issuer, audience and expiry. &lt;a href="https://www.rfc-editor.org/rfc/rfc7519" rel="noopener noreferrer"&gt;JWT itself&lt;/a&gt; supports other forms, including encryption, and does not require every application to use the same claims.&lt;/p&gt;

&lt;p&gt;For our bearer tokens, verification does not establish who is holding the token now. Nor does a signature make an unverified claim true. If an issuer copies a caller-supplied label into &lt;code&gt;sub&lt;/code&gt; without authenticating that caller, the downstream API cannot turn it into an authenticated identity by checking the signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What our issuer checks before it signs
&lt;/h2&gt;

&lt;p&gt;Our issuer mints tokens for a tenant id. The endpoint is public. Before it signs, it asks the client to solve a small proof-of-work, which makes minting cost CPU rather than a login. The proof-of-work is a cost, not a gate: a script that is willing to spend the cycles gets a token. The issuer checks that the tenant is a legitimate customer when it creates that tenant's signing key; after that, every request for that tenant is served within the rate limits.&lt;/p&gt;

&lt;p&gt;Each tenant has its own RS256 key pair, the public half is published as a JWKS per tenant, and tokens live fifteen minutes. After verification, the proxy has a tenant label backed by our minting process: somebody completed its challenge for that tenant recently enough for the token to remain valid. That somebody may be an untrusted caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question we stopped asking
&lt;/h2&gt;

&lt;p&gt;We first considered a signed context from the native app: something the mobile runtime could attach to a request, independently of the page. Our production SDK does not provide that, and the web version of the widget has no native runtime to ask.&lt;/p&gt;

&lt;p&gt;So we designed for the caller who can obtain a valid token without earning our trust. The proxy has to enforce three constraints even for that caller.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The secret never leaves the server.&lt;/strong&gt; The third-party key exists in plaintext only in the proxy's memory, for the duration of one outbound request. It is not in the page, not in the bundle, not in the response, not in the logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress is bounded by what was provisioned, not by what the caller asks.&lt;/strong&gt; A connector is registered by an authenticated developer with a host, a base path and a list of verbs. The widget sends a relative path. It never sends a host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuse is capped.&lt;/strong&gt; Per-tenant and per-connector rate limits, a kill switch per connector, and a global one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order of operations follows from the first invariant. In pseudo-code, one request through the proxy looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify token             -&amp;gt; 401 if invalid; 503 if verification keys are unavailable
resolve connector        -&amp;gt; by (tenant from token, handle from URL), no secret loaded
check kill switch        -&amp;gt; 503
check verb allowlist     -&amp;gt; 403
check path allowlist     -&amp;gt; 403
parse destination        -&amp;gt; host must match the provisioned allowlist
resolve DNS and pin IP   -&amp;gt; block private ranges, keep Host and SNI for TLS
count against limits     -&amp;gt; 429
decrypt the secret       -&amp;gt; only now
inject, forward, redact  -&amp;gt; scrub the key and the credential forms our injectors construct
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those admission checks run before decryption. On the way back, the response is scrubbed against the key and the credential forms the injectors construct, including a Bearer header or a base64 Basic pair.&lt;/p&gt;

&lt;p&gt;A caller with a valid token can still invoke a provisioned connector, within its permitted host, path and verbs, until the quota says stop. The controls are intended to prevent extracting the key or redirecting its use beyond that scope. They do not prevent every harmful use within it. Burning someone else's API quota is one such use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the token still buys
&lt;/h2&gt;

&lt;p&gt;The issuer still has useful work to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution and cost.&lt;/strong&gt; Every request carries a tenant id that the caller had to spend something to obtain, and every abuse has a label. Rate limits per tenant only make sense when the tenant claim cannot be free-typed by the caller. The signature is what makes the label trustworthy, even though the label is not an identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser restrictions.&lt;/strong&gt; The mint runs inside an iframe served by the issuer. Its allowed parent origins come from our tenant registry, and the proxy applies the same per-tenant list to CORS. An unrelated site cannot simply embed that mint in its own page. A client outside the browser remains a different case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The native limit.&lt;/strong&gt; Our iOS plugin pages share one fixed origin across apps. Android's asset-loader origin is shared by other apps using that loader too. Neither origin distinguishes one app installation from another. The token's minting cost and the proxy's quotas still apply, but an origin check adds no installation identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A possible extension.&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc9449" rel="noopener noreferrer"&gt;DPoP, specified in RFC 9449&lt;/a&gt;, can bind an access token to a client-held key and require a proof with each request. Adopting it would require validation of that proof and its binding to the token at the resource server. It would add a possession check; identifying the person behind the key would remain a separate question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the residual risk down
&lt;/h2&gt;

&lt;p&gt;Before writing the proxy, we put the remaining risk into a paragraph of the security specification and recorded where we accepted it.&lt;/p&gt;

&lt;p&gt;The paragraph says, in substance: anyone who can mint a token for a tenant and knows a connector handle, which is public in the bundle, can invoke that connector within its declared scope, burn quota or trigger the side effects the connector allows, without ever extracting the key. It lists the five things that bound that risk: the secret is not exfiltrable, the allowlist is per credential, the limits, the kill switch, and the upstream control over who can publish code into a tenant's app. It says the risk is accepted for read-only connectors and that side-effecting ones need tighter quotas and idempotency.&lt;/p&gt;

&lt;p&gt;That paragraph gave reviewers specific questions to investigate: could a redirect expose the secret? An error page? A log line or a cache? Several findings produced fixes. The specification grew to 108 numbered requirements, each phrased so that a test can fail it. The risk paragraph explains why those requirements exist and which harms can remain when they all hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would change
&lt;/h2&gt;

&lt;p&gt;Three changes remain on our list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated audience for each consumer of the issuer, before a second consumer exists. A token meant for one service should not verify on another.&lt;/li&gt;
&lt;li&gt;Per-tenant quota overrides on the proxy, before opening the feature beyond the current pilot tenants. Aggregate limits per connector are the right posture against abuse and the wrong posture for a popular app.&lt;/li&gt;
&lt;li&gt;A signed install context, the day the native SDK can provide one. The proxy will accept it as an additional check, not as a replacement for the invariants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On September 16 I verified the same published widget on the web, on an iOS simulator and on an Android emulator: one GET and one POST through the proxy, the response redacted, the key absent from the page. Those checks exercised the request path. The adversarial reviews examined specific ways to escape it. Neither is a proof that no other attack exists.&lt;/p&gt;

&lt;p&gt;An API using this design has to be willing to grant the connector's declared capabilities to someone who can obtain the public token. If a capability requires an identified user, that identity needs its own authentication step. Quotas and a signature cannot supply it.&lt;/p&gt;

&lt;p&gt;Before trusting a claim downstream, I want to know which check caused the issuer to write it. Which claim in your API would be hardest to trace back to that check?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>authentication</category>
      <category>http</category>
    </item>
    <item>
      <title>How do you stop an LLM from leaking API keys in the code it writes? Default to secret</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Tue, 22 Sep 2026 12:00:55 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/how-do-you-stop-an-llm-from-leaking-api-keys-in-the-code-it-writes-default-to-secret-4ok2</link>
      <guid>https://dev.to/pierrelaurentmedori/how-do-you-stop-an-llm-from-leaking-api-keys-in-the-code-it-writes-default-to-secret-4ok2</guid>
      <description>&lt;p&gt;On July 22 I typed a one-line prompt on a test app: a widget built on a well-known REST API, the kind that authenticates with an &lt;code&gt;X-Api-Key&lt;/code&gt; header. The model wrote a clean widget. It also wrote my key into a request header, in the page's JavaScript. In the declaration that comes with the code, it had marked the API as not secret.&lt;/p&gt;

&lt;p&gt;It was my own key, on my own test app. At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt; we are piloting a builder in which an app owner describes an extension and a model generates its code. Nothing had been released yet, and a review before release is the cheapest place to find a key in the wrong spot.&lt;/p&gt;

&lt;p&gt;What we changed is the answer to the question in the title. We do not scan generated code for secrets, and we do not let the model decide whether a key is secret. The model declares every key an API needs and states one fact about it: how the key is sent. Code does the classification: a key is secret unless it belongs to a family its provider documents as public. The value goes from a masked field to a server-side proxy. It never reaches the model, the page or the bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  A key that looked public
&lt;/h2&gt;

&lt;p&gt;The model was not being careless. The keys it treated as secret without being told were the famous ones, AI providers and payment secret keys. A forty-character string with no prefix, sent in a custom header to a REST API, looks like what front-end tutorials do all day. And that reading is not absurd, because some keys really are public.&lt;/p&gt;

&lt;p&gt;I have written on the company blog that &lt;a href="https://www.goodbarber.com/blog/vibe-coding-is-magic-in-the-demo-is-your-app-really-production-ready-a1558/" rel="noopener noreferrer"&gt;security is the best-documented wall between a demo and production&lt;/a&gt;. This is what that wall looks like from the inside, on a Wednesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some keys are public by design
&lt;/h2&gt;

&lt;p&gt;Stripe lists its &lt;a href="https://docs.stripe.com/keys" rel="noopener noreferrer"&gt;publishable key&lt;/a&gt; as safe to expose. Firebase documents its &lt;a href="https://firebase.google.com/docs/projects/api-keys" rel="noopener noreferrer"&gt;API keys&lt;/a&gt; as public by design. Supabase documents its &lt;a href="https://supabase.com/docs/guides/api/api-keys" rel="noopener noreferrer"&gt;publishable key&lt;/a&gt; as safe to expose online, and Algolia its &lt;a href="https://www.algolia.com/doc/guides/security/api-keys/" rel="noopener noreferrer"&gt;search-only key&lt;/a&gt; as safe for production front-end code. These keys identify a project. Authorization happens elsewhere: security rules, row-level policies, restricted scopes.&lt;/p&gt;

&lt;p&gt;So "never put a key in the page" is wrong, and a platform that enforced it would break every one of those integrations. The real question is classification. A model will get that call right many times and wrong some times, and one wrong call publishes a key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we did not scan the code
&lt;/h2&gt;

&lt;p&gt;The reflex is to search the generated JavaScript for things that look like keys. Two things argued against it.&lt;/p&gt;

&lt;p&gt;We had already lived through pattern-matching on generated code. An earlier validator matched unsafe JavaScript constructs with a list of regular expressions. It produced enough false positives to be switched off, and I deleted it on July 16. The design notes for the secrets work say it in one line: the secret guard must not reintroduce pattern-matching on generated code.&lt;/p&gt;

&lt;p&gt;And most secrets do not look like anything. GitHub redesigned its token format in 2021 because its old tokens were hard to tell from a SHA hash; with identifiable prefixes, it &lt;a href="https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/" rel="noopener noreferrer"&gt;expected the false positive rate of secret scanning to fall to 0.5 %&lt;/a&gt;. That works when the provider cooperates. GitGuardian's &lt;a href="https://www.gitguardian.com/state-of-secrets-sprawl-report-2025" rel="noopener noreferrer"&gt;2025 report&lt;/a&gt; counts 23,770,171 new hardcoded secrets in public GitHub commits in 2024, and classifies 58 % of the leaks it detected as generic credentials, the ones no provider-specific pattern describes. The key in my widget was one of those. Forty characters, no prefix.&lt;/p&gt;

&lt;p&gt;A scanner would have been blind to the exact key it was built for, and noisy about everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision with a tripwire
&lt;/h2&gt;

&lt;p&gt;Here is the part I am glad we wrote down. In the plan, dated July 13, the model's &lt;code&gt;secret&lt;/code&gt; flag was trusted, and a stricter guard, secret unless proven public, was considered and deferred. Deferred with a condition, in writing: revisit if a leak by under-classification is observed.&lt;/p&gt;

&lt;p&gt;It was observed on July 22, on my test app. The stricter guard was committed the same afternoon, at 15:01.&lt;/p&gt;

&lt;p&gt;A deferred safeguard without a written trigger is a safeguard that "later" never schedules. The trigger turned a debate into a lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default to secret, decided by code
&lt;/h2&gt;

&lt;p&gt;The fix has two layers, and neither reads generated code. Both read the declaration: the structured list of APIs and keys that the model must produce alongside the widget.&lt;/p&gt;

&lt;p&gt;The first layer is the instruction given to the model. Its header says "DEFAULT TO SECRET when unsure", and its core is one rule: a key is secret unless the provider explicitly documents it as public, publishable, anon, browser or search-only, and a key that authenticates your account is secret no matter how it is sent, a custom header and a query parameter included.&lt;/p&gt;

&lt;p&gt;The second layer is the one that matters, because an instruction is a request. A validator checks the declaration. An API declared as not secret, whose key does not belong to a family that providers document as browser-safe, is rejected. The rejection feeds the retry loop we already had, with a message that tells the model to route the call through the proxy helper. The exemption reads the structured fields of the declaration, never its prose: a description saying "get it from the public dashboard" must not make a key public.&lt;/p&gt;

&lt;p&gt;The model can still raise a key to secret. It can no longer lower one. Only evidence can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the model for facts, not for judgment
&lt;/h2&gt;

&lt;p&gt;The next morning showed that the rule was not enough. Reclassifying a key as secret is useless if nobody knows how to send it. To wire a server-side connector, the platform needs to know whether the key travels as a bearer token, a named header, a query parameter or a path segment. The model had been asked for that only when it judged the key secret. A key it judged public arrived with a name and a label, and nothing to build a connector from.&lt;/p&gt;

&lt;p&gt;The instruction now asks for the transport of every key, always, whatever the model thinks of its secrecy. The prompt says why in its own words: it is "a FACTUAL property of the API, not a consequence of the secret-vs-public call".&lt;/p&gt;

&lt;p&gt;This is the distinction I would keep if I had to drop everything else. Where does this API expect its key, a header called &lt;code&gt;X-Api-Key&lt;/code&gt;, a query parameter called &lt;code&gt;appid&lt;/code&gt;? That is documented, and models are good at documented facts. Is this key dangerous in a browser? That is a judgment about consequences, and the model's prior is a web full of inlined keys. So the contract asks for the fact unconditionally, and code makes the judgment.&lt;/p&gt;

&lt;p&gt;The same part of the prompt carries the inverse rule: never invent a fact. When an API authenticates with an OAuth2 token exchange or per-request signing, none of the declared transports describes it. The model is told to say so plainly and leave the API out, rather than declare a static bearer token that would pass validation, collect a key, and fail with a 401 on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same rule at every door
&lt;/h2&gt;

&lt;p&gt;One more place needed the rule. Our generation runs in two phases: a plan the owner approves, then the code. The key is requested when the plan is approved, before any code exists. A guard that runs on generated code arrives too late, because the question "is this a secret?" has already been answered by then. The classification now runs at the single point where all four completion paths collect what must be asked of the owner.&lt;/p&gt;

&lt;p&gt;A classification rule has to live at every boundary the value can cross. Otherwise the earliest boundary wins.&lt;/p&gt;

&lt;p&gt;There is a second net where the value itself enters, the consent form and the credentials screen. If a pasted value carries an unambiguous vendor signature, &lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;xoxb-&lt;/code&gt;, &lt;code&gt;AKIA&lt;/code&gt;, a PEM header, it is routed to the proxy even when its declaration said public. That net is deliberately narrow, because it must never match a publishable key. Ambiguity is handled upstream, by the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-five out of fifty-one
&lt;/h2&gt;

&lt;p&gt;A default that leans towards "secret" can overcorrect. Two weeks later one of ours did, in a neighbouring rule, for one day. It is the part of this story I find most useful, because it is the opposite failure.&lt;/p&gt;

&lt;p&gt;Some APIs require a non-secret header on every call: a &lt;a href="https://docs.anthropic.com/en/api/versioning" rel="noopener noreferrer"&gt;version header&lt;/a&gt; for Anthropic or Notion, a host header for RapidAPI. We let the model declare those as constants. Constants are stored in clear, so a constant must never carry credential material, and the failure to guard against is the model splitting a pair like &lt;code&gt;X-RapidAPI-Host&lt;/code&gt; and &lt;code&gt;X-RapidAPI-Key&lt;/code&gt; the wrong way round.&lt;/p&gt;

&lt;p&gt;The first guard, written on August 4, refused any constant whose name contained one of ten markers, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt; and &lt;code&gt;auth&lt;/code&gt; among them. It looked strict and safe. The next day a review pass ran it against 51 header names taken from real API documentation, and it refused 25 of them. &lt;code&gt;X-Auth-Email&lt;/code&gt;, which Cloudflare &lt;a href="https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/" rel="noopener noreferrer"&gt;pairs with &lt;code&gt;X-Auth-Key&lt;/code&gt;&lt;/a&gt;, is an e-mail address. &lt;code&gt;X-Auth-Client&lt;/code&gt;, &lt;code&gt;X-Auth-User&lt;/code&gt;, &lt;code&gt;X-Oauth-Version&lt;/code&gt;, &lt;code&gt;Authority&lt;/code&gt;: all legitimate, all refused.&lt;/p&gt;

&lt;p&gt;A false positive here is not a harmless excess of caution. A refused constant means a rejected declaration, a retry loop that burns its attempts, and an API the owner legitimately wants that cannot be integrated at all. The same day, a sibling rule turned out to forbid &lt;code&gt;Authorization&lt;/code&gt; as the place where a key is injected, which is exactly where some APIs expect it. Two over-blocks in one family, fixed the same morning.&lt;/p&gt;

&lt;p&gt;In the names we tested, the last segment carried the meaning. &lt;code&gt;X-Auth-Key&lt;/code&gt; is a secret. &lt;code&gt;X-Auth-Email&lt;/code&gt; is not. The guard now splits the name on its separators and reads the last segment only, against a slightly wider list of markers. Rerun the same day: 21 declaration patterns from real APIs accepted, then 12 more, and 7 out of 7 secret-carrying names still refused.&lt;/p&gt;

&lt;p&gt;That is an observation about those families of headers, not a law of HTTP. &lt;code&gt;Idempotency-Key&lt;/code&gt; ends in &lt;code&gt;key&lt;/code&gt; and is not a secret. As a constant it is still refused, and I can live with that: an idempotency key changes with every request and has no business being a constant. A heuristic earns its place by being measured on real names, in both directions.&lt;/p&gt;

&lt;p&gt;Twenty-five out of fifty-one. A guard that refuses half of the legitimate world is not strict. It is wrong in the other direction, and people route around guards that are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose which way to be wrong
&lt;/h2&gt;

&lt;p&gt;The errors now fall on the cheap side. A public key classified as secret costs one proxied call instead of a direct one: slower, and no key is disclosed. A secret key classified as public costs the key. When a classifier has to be wrong sometimes, choose which way, then measure the other direction too.&lt;/p&gt;

&lt;p&gt;If a model writes code that calls paid APIs on behalf of your users, where does the decision "this key is secret" live in your system: in a prompt, in a scanner, or in code that can say no?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Listing a remote MCP server in every directory: what each one actually checks (September 2026)</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:52:00 +0000</pubDate>
      <link>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</link>
      <guid>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</guid>
      <description>&lt;p&gt;The Save button did nothing. We opened the network panel. The request had returned HTTP 200.&lt;/p&gt;

&lt;p&gt;Inside the response: a validation error on a field the form did not show.&lt;/p&gt;

&lt;p&gt;By then we had spent several days trying to get the same remote MCP server listed across directories. Another directory had published us in about ten minutes. A GitHub list had tested our endpoint and accepted its 401. Elsewhere, our submission was waiting for a human.&lt;/p&gt;

&lt;p&gt;"Get the server listed" had turned into a collection of different jobs.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection. It is hosted, it requires OAuth, and there is no public server code for a directory to scan: our public repository holds skills and client configuration. That distinction decided which submission paths worked.&lt;/p&gt;

&lt;p&gt;After &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;getting the server into ChatGPT's plugin directory&lt;/a&gt;, we widened the search. What follows happened between September 7 and 14, and every public state was checked again on September 14. The delays are the ones our submissions went through, not a promise from anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official MCP Registry: publish the identity first
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/registry/about" rel="noopener noreferrer"&gt;official MCP Registry&lt;/a&gt; accepts metadata for hosted servers, closed-source implementations included, as long as the server is publicly reachable. It authenticates the publisher's namespace and validates the metadata. Beyond that, its metadata is deliberately unopinionated, and curation is left to the aggregators downstream.&lt;/p&gt;

&lt;p&gt;Our entry, &lt;code&gt;dev.goodbarber/goodbarber-public-mcp&lt;/code&gt;, has been there since April 28 (version 1.0.0). On September 7 we published version &lt;code&gt;1.2.1&lt;/code&gt;, with DNS authentication: it declares &lt;code&gt;streamable-http&lt;/code&gt;, the remote endpoint and the product page. On September 14 the &lt;a href="https://registry.modelcontextprotocol.io/v0.1/servers?search=dev.goodbarber&amp;amp;limit=100" rel="noopener noreferrer"&gt;registry API&lt;/a&gt; still marked it active and latest.&lt;/p&gt;

&lt;p&gt;We would do this first again. It gives importers a stable identity and a record to consume, instead of asking each directory to rebuild the product from a README.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://glama.ai/mcp/connectors/dev.goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;Glama&lt;/a&gt; had already imported a connector entry from that record. We claimed it, and on September 8 it displayed "Ownership verified" and "Healthy". Both were still there on September 14.&lt;/p&gt;

&lt;p&gt;Upstream publication does not tell you when the downstream pages will exist. MCP.Directory says it auto-discovers servers from the official registry, where our entry has been since April. Its submission form promises a review within 24 hours, and its first, required field is a GitHub repository, from which it detects the tools by analyzing the server's implementation. The Knights Who Say Ni wanted a shrubbery; a hosted server with no public code has no repository to give. On September 14, MCP.Directory's &lt;a href="https://mcp.directory/sitemap.xml" rel="noopener noreferrer"&gt;sitemap&lt;/a&gt; still had no GoodBarber URL, six days after a server listing request and five skill submissions. Where those submissions sit, we do not know.&lt;/p&gt;

&lt;h2&gt;
  
  
  awesome-remote-mcp-servers: the CI that accepts a 401
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt; is built for our shape of product. It only lists servers the provider hosts: reachable at a public URL, usable by anyone who can sign up, speaking Streamable HTTP or SSE. Each entry's name links to the product's homepage, not to a GitHub repository, because the endpoint is the thing being listed. The list was created on September 8, and our PR went in the same morning.&lt;/p&gt;

&lt;p&gt;It also gave us something we could test. Its &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/a3bb155f3a2ad0cc159781f919fc78c187b84bec/.github/workflows/check-submission.yml" rel="noopener noreferrer"&gt;submission workflow at the merge commit&lt;/a&gt; probes each endpoint with a real &lt;code&gt;initialize&lt;/code&gt; handshake and sends no credentials. We replayed the check before submitting. Our endpoint answered with an authentication challenge, structurally like this (generic domain):&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow takes a 401 or a 403 as a live endpoint that requires authentication, and reads the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header to choose between the OAuth and API-key markers. Our 401 passed with the OAuth marker. The probe never completes OAuth and never calls a tool: a green check here means the door exists.&lt;/p&gt;

&lt;p&gt;Two more rules matter before you open the PR. Every entry needs a Glama &lt;strong&gt;connector&lt;/strong&gt; badge, and CI checks that the connector exists. And the &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;contribution rules&lt;/a&gt; document a fast track for automated agents: three robot emoji at the end of the PR title. Our PR was prepared by an agent, so we used it. &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/pull/4" rel="noopener noreferrer"&gt;PR #4&lt;/a&gt; was opened at 07:23 UTC, labeled &lt;code&gt;endpoint-ok&lt;/code&gt; and &lt;code&gt;has-connector&lt;/code&gt; by CI nine seconds later, and merged at 13:50 UTC, on September 8.&lt;/p&gt;

&lt;p&gt;Then the bot ran again on the merged PR. It added &lt;code&gt;duplicate&lt;/code&gt; and &lt;code&gt;missing-connector&lt;/code&gt;, removed &lt;code&gt;has-connector&lt;/code&gt;, and asked us to remove the duplicate entry. The duplicate was ours: the check was now reading the list it had just merged. The README on the main branch settled what the labels could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gemini CLI and GitHub: a crawler versus a review queue
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://geminicli.com/docs/extensions/releasing/" rel="noopener noreferrer"&gt;Gemini CLI gallery instructions&lt;/a&gt; describe a daily crawl of public GitHub repositories carrying the &lt;code&gt;gemini-cli-extension&lt;/code&gt; topic. A &lt;code&gt;gemini-extension.json&lt;/code&gt; manifest at the root of the repository supplies the metadata, and an extension appears in the gallery only if it passes validation. The instructions say there is no issue to file and no email to send.&lt;/p&gt;

&lt;p&gt;We added the topic on September 9. On September 14 the topic and the manifest were in place, and the &lt;a href="https://geminicli.com/extensions/" rel="noopener noreferrer"&gt;gallery page&lt;/a&gt; had no GoodBarber entry. A daily crawl gives no per-repository result, so we cannot tell whether we failed validation or were never picked up.&lt;/p&gt;

&lt;p&gt;GitHub's MCP gallery has another gate. In &lt;a href="https://github.com/github/github-mcp-server/discussions/1257" rel="noopener noreferrer"&gt;discussion #1257&lt;/a&gt;, a GitHub answer explains that onboarding a new server is a manual curation process, and that new registry versions sync once a server has been onboarded. We asked to be included on September 8; on September 14 the expected gallery URL still returned 404. Republishing the registry record would change nothing there.&lt;/p&gt;

&lt;p&gt;Our Claude Code plugin sits in a similar waiting room. Plugins are submitted through a form, and the public &lt;a href="https://github.com/anthropics/claude-plugins-community/blob/main/.claude-plugin/marketplace.json" rel="noopener noreferrer"&gt;community marketplace catalogue&lt;/a&gt; is a read-only mirror, synced nightly from Anthropic's review pipeline, of the plugins that passed automated security scanning and were approved. Ours was submitted on September 7, shown as awaiting review on September 8, and still absent from that catalogue on September 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude connectors: ten minutes, then an inventory problem
&lt;/h2&gt;

&lt;p&gt;The Claude &lt;strong&gt;connectors directory&lt;/strong&gt; is a different destination from the Claude Code plugin marketplace. Remote servers are submitted from an organization's admin settings, through a portal that connects to the server. Our &lt;a href="https://claude.ai/directory/424480c2-9956-40d4-8af3-5db873be30ed" rel="noopener noreferrer"&gt;connector listing&lt;/a&gt; appeared about ten minutes after we submitted it on September 8, while the plugin was still waiting. Its tier is &lt;strong&gt;community&lt;/strong&gt;, and that is the word we use.&lt;/p&gt;

&lt;p&gt;Then we read the published inventory and found our own mistake. The directory's &lt;a href="https://api.anthropic.com/api/directory/servers?limit=5000&amp;amp;visibility=commercial,gsuite,gsuite-google&amp;amp;verified_tier=anthropic,partner,community" rel="noopener noreferrer"&gt;public record&lt;/a&gt; listed 120 tool names and left out an entire tool family, the one behind push notifications and analytics, while the description we wrote advertised both. The portal syncs the tool list from the server through the connection made at submission, and our connection did not expose that family. The description is ours to edit from the dashboard; the tool list is not an editable field, and changing it goes through the review team. Either way, the fix is ours.&lt;/p&gt;

&lt;p&gt;For someone following the &lt;a href="https://www.goodbarber.com/connect-claude-app/" rel="noopener noreferrer"&gt;Claude connection guide&lt;/a&gt;, the only question that matters is whether the tools they get cover the task they came for. A directory listing does not change the tools a connection exposes, so the check we owe that reader goes beyond the listing: the published inventory against the description, then a real connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  mcp.so: HTTP 200, application-level failure
&lt;/h2&gt;

&lt;p&gt;The Save button from the opening was on mcp.so. We were updating an existing listing whose transport and overview had gone stale.&lt;/p&gt;

&lt;p&gt;On September 8 the edit request returned HTTP 200, and its body carried application code &lt;code&gt;-1&lt;/code&gt;, an "Invalid submission" message and a validation error under &lt;code&gt;fieldErrors.tagline&lt;/code&gt;. The old tagline was too long. The form had no field to fix it and showed no error.&lt;/p&gt;

&lt;p&gt;We sent the edit again with a shorter tagline, then read the public page, because a save response is a claim, not a result. On September 14 the &lt;a href="https://mcp.so/servers/goodbarber-skills" rel="noopener noreferrer"&gt;listing&lt;/a&gt; showed Streamable HTTP, OAuth and the updated overview.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, it is &lt;a href="https://dev.to/pierrelaurentmedori/your-mcp-write-returned-200-did-the-right-thing-actually-happen-38n0"&gt;the write-safety piece&lt;/a&gt; in miniature, with a web form in place of an agent. The green request in the network panel meant an HTTP exchange had completed, nothing more. Read the body, then read the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP Market: fast, paid, and one button to check
&lt;/h2&gt;

&lt;p&gt;On September 8 we paid $69 for the remote-server "Official" listing option, which is a different product from the GitHub-repository submission. The &lt;a href="https://mcpmarket.com/server/goodbarber" rel="noopener noreferrer"&gt;listing&lt;/a&gt; was live the next morning, inside the advertised 24 hours, with the badge and the categories we asked for.&lt;/p&gt;

&lt;p&gt;Search MCP Market for GoodBarber and a second card shows up next to ours: GoodBarber Skills, filed under Marketing Automation.&lt;/p&gt;

&lt;p&gt;Then we clicked &lt;strong&gt;Try Now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We had submitted our MCP product page as the destination. The published button led to our app-creation page, with MCP Market campaign parameters added, and still did on September 14. Both pages are ours, and it matters anyway: someone looking for connection instructions lands on a different step of the product journey.&lt;/p&gt;

&lt;p&gt;Paying got the listing published fast. Checking where it sends people stayed our job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smithery and skills.sh: read what the signal measures
&lt;/h2&gt;

&lt;p&gt;On Smithery, verification happens in the server's settings, through what its publishing guide calls an automatic official-vendor verification checklist. Our domain and backlink proofs passed their recheck on September 8. The public data embedded in the &lt;a href="https://smithery.ai/servers/goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;listing page&lt;/a&gt; said &lt;code&gt;verified: false&lt;/code&gt; afterwards, and still did on September 14. The guide names the checklist without listing its items, and nothing we could see explains the step between two passed proofs and a false flag. We write down both states rather than calling the whole thing verified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.sh/goodbarber/goodbarber-skills" rel="noopener noreferrer"&gt;skills.sh&lt;/a&gt; showed our 44 skills on September 14, with installation counts. There is nothing to submit there: skills appear through the anonymous install telemetry of its CLI, when people run &lt;code&gt;npx skills add&lt;/code&gt;. Those skills are instruction packages for using the server, and the counter measures installs: not a completed workflow, and not an OAuth flow that works in every client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The September 14 snapshot
&lt;/h2&gt;

&lt;p&gt;Every state below was checked on September 14, 2026; submission and publication dates are the real ones. Read "pending" as pending, and "absent" as unexplained.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directory or catalogue&lt;/th&gt;
&lt;th&gt;Admission mechanism&lt;/th&gt;
&lt;th&gt;Observed delay&lt;/th&gt;
&lt;th&gt;State on September 14&lt;/th&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official MCP Registry&lt;/td&gt;
&lt;td&gt;Namespace authentication, metadata validation&lt;/td&gt;
&lt;td&gt;Entry since April 28; 1.2.1 on September 7&lt;/td&gt;
&lt;td&gt;Version 1.2.1 active and latest&lt;/td&gt;
&lt;td&gt;Curation is left to aggregators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glama&lt;/td&gt;
&lt;td&gt;Registry import, ownership claim, health checks&lt;/td&gt;
&lt;td&gt;Initial import not timed&lt;/td&gt;
&lt;td&gt;Ownership verified; Healthy&lt;/td&gt;
&lt;td&gt;Ownership and health are separate signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Endpoint CI without credentials, Glama connector badge, PR&lt;/td&gt;
&lt;td&gt;Same day, September 8&lt;/td&gt;
&lt;td&gt;PR #4 merged&lt;/td&gt;
&lt;td&gt;The probe stops at the 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP.Directory&lt;/td&gt;
&lt;td&gt;Registry auto-discovery; GitHub-based form with a 24-hour review promise&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No GoodBarber URL in sitemap&lt;/td&gt;
&lt;td&gt;The form expects a GitHub repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI gallery&lt;/td&gt;
&lt;td&gt;Topic crawl, root manifest, validation&lt;/td&gt;
&lt;td&gt;September 9 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No entry on the gallery page&lt;/td&gt;
&lt;td&gt;A daily crawl gives no per-repo result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub MCP gallery&lt;/td&gt;
&lt;td&gt;Manual curation, then version sync&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;Expected URL returns 404&lt;/td&gt;
&lt;td&gt;Registry publication does not grant admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code community marketplace&lt;/td&gt;
&lt;td&gt;Submission form, security scan, approval, nightly mirror&lt;/td&gt;
&lt;td&gt;September 7 to 14, unpublished&lt;/td&gt;
&lt;td&gt;Absent from public catalogue&lt;/td&gt;
&lt;td&gt;Separate from the connectors directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude connectors directory&lt;/td&gt;
&lt;td&gt;Admin portal; tools synced from the connected server&lt;/td&gt;
&lt;td&gt;About 10 minutes, September 8&lt;/td&gt;
&lt;td&gt;Community listing; 120 declared tools, no push or analytics&lt;/td&gt;
&lt;td&gt;The synced tools did not match our description&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mcp.so&lt;/td&gt;
&lt;td&gt;Authenticated form and API validation&lt;/td&gt;
&lt;td&gt;Update completed September 8&lt;/td&gt;
&lt;td&gt;Corrected transport and overview live&lt;/td&gt;
&lt;td&gt;HTTP 200 carried a validation failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP Market&lt;/td&gt;
&lt;td&gt;Paid remote-server submission&lt;/td&gt;
&lt;td&gt;Live the next morning, September 9&lt;/td&gt;
&lt;td&gt;Official listing live; a GoodBarber Skills card also listed&lt;/td&gt;
&lt;td&gt;Try Now destination changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smithery&lt;/td&gt;
&lt;td&gt;Official-vendor verification checklist&lt;/td&gt;
&lt;td&gt;Proofs passed September 8&lt;/td&gt;
&lt;td&gt;Public data still says &lt;code&gt;verified: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Passed proofs did not flip the public flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skills.sh&lt;/td&gt;
&lt;td&gt;No submission; CLI install telemetry&lt;/td&gt;
&lt;td&gt;First install not timed&lt;/td&gt;
&lt;td&gt;44 skills listed&lt;/td&gt;
&lt;td&gt;Installs are not workflow success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/docker/mcp-registry/pull/4968" rel="noopener noreferrer"&gt;Docker MCP Catalog&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Remote-server PR, Docker team review, test credentials by form&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #4968 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review outcome unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/VoltAgent/awesome-agent-skills/pull/1031" rel="noopener noreferrer"&gt;VoltAgent skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR; rules require real community usage&lt;/td&gt;
&lt;td&gt;September 8 to 14&lt;/td&gt;
&lt;td&gt;PR #1031 closed without merge or comment&lt;/td&gt;
&lt;td&gt;Adoption comes before the listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/BehiSecc/awesome-claude-skills/pull/689" rel="noopener noreferrer"&gt;BehiSecc skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #689 open; 0 comments&lt;/td&gt;
&lt;td&gt;Skills listings have their own review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/cline/mcp-marketplace/issues/2492" rel="noopener noreferrer"&gt;Cline marketplace&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Submission issue; review of adoption, credibility, maturity, security&lt;/td&gt;
&lt;td&gt;Open since September 9&lt;/td&gt;
&lt;td&gt;Issue #2492 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review weighs community adoption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The order we would follow next time
&lt;/h2&gt;

&lt;p&gt;Start with the official registry, and with one description, one endpoint, one product page and one documentation URL, the same everywhere. Separate the hosted server from the skills package before choosing routes: some submission forms start with a GitHub repository, and a hosted server has nothing to put in that field.&lt;/p&gt;

&lt;p&gt;Then read each gate before you submit. Replay public CI checks. If an agent prepares the PR, use the documented agent fast track. For a crawler, check the topic and the manifest. For a curated catalogue, read the acceptance criteria first, since some weigh community adoption, then keep the submission reference and wait for evidence of admission.&lt;/p&gt;

&lt;p&gt;After publication, read what went live: the tool inventory, the authentication metadata, every destination link. Keep dates next to results and recheck weekly. The merged PR, the badge and the live page can tell three different stories.&lt;/p&gt;

&lt;p&gt;All of it points to one page, &lt;a href="https://www.goodbarber.com/mcp-complete-guide/" rel="noopener noreferrer"&gt;the connection guide&lt;/a&gt;. If you are connecting GoodBarber to an assistant, start there.&lt;/p&gt;

&lt;p&gt;One more thing: that was a dense week, and these procedures move fast. What you read above comes from each directory's own documentation where it has one, and from what we saw on the forms otherwise, checked on September 14. If you run one of these directories and something here is wrong, or already out of date, tell us in the comments.&lt;/p&gt;

&lt;p&gt;Which check caught a mismatch in your own listing: the response body, the tool inventory, or the link after publication? Genuinely curious.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devrel</category>
    </item>
    <item>
      <title>Fifteen years of the same click: what the agent era keeps rediscovering about distributed systems</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Wed, 16 Sep 2026 11:46:07 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/fifteen-years-of-the-same-click-what-the-agent-era-keeps-rediscovering-about-distributed-systems-226e</link>
      <guid>https://dev.to/pierrelaurentmedori/fifteen-years-of-the-same-click-what-the-agent-era-keeps-rediscovering-about-distributed-systems-226e</guid>
      <description>&lt;p&gt;On September 11, I left a comment under Tilde Thurium's &lt;a href="https://dev.to/googleai/is-graph-engineering-just-reinventing-systems-architecture-for-the-ai-age-2427"&gt;post about graph engineering&lt;/a&gt;. &lt;a href="https://dev.to/hannune/comment/3ehog"&gt;Tae Kim had described&lt;/a&gt; the moment the term clicked: the microservices analogy turned the graph from relationships in a knowledge graph into an agent's control flow, something you can inspect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/pierrelaurentmedori/comment/3eif2"&gt;I replied&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same click here, on several subjects lately. Fifteen years of systems architecture, and I still catch myself arriving, after a day of thinking, at something that was a reflex in microservices four years ago. But what strikes me most is how similar the subjects turn out to be.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The part I meant was the day of thinking. Recognizing an old problem eventually is not the achievement. Recognizing it before spending the day on it would be.&lt;/p&gt;

&lt;p&gt;That week I had been discussing idempotent webhooks, then a reconciliation cron with a dead-letter queue, then graph engineering. Different conversations, different starting points, and each one ended on a mechanism I already knew, after I had spent time treating the problem as new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Click one: the double charge
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://www.reddit.com/r/nocode/comments/1wbktqp/" rel="noopener noreferrer"&gt;a discussion on r/nocode about verifying integrations&lt;/a&gt;, the conversation came to duplicate effects when a webhook is retried. My first reaction: a second charge exposes a missing idempotency guarantee. Testing can reveal that gap. Sending another request does not fill it.&lt;/p&gt;

&lt;p&gt;The follow-up was about concurrency. Fair point. Send the same event twice, one after the other, and the second request finds the record the first one left. That shows the check works after a completed write. The bug lives in the other case, two requests arriving together.&lt;/p&gt;

&lt;p&gt;Both ask whether the event has been processed. Both see that it has not. Both proceed. Every line of code behaves exactly as written, and the system does the wrong thing twice.&lt;/p&gt;

&lt;p&gt;For duplicate deliveries of one event, put a non-null database uniqueness constraint on its identity, scoped to the provider and the account where needed. Attempt that insert before the local business writes, and let only the successful insertion proceed. Commit the deduplication record and those writes in the same transaction, so a rollback leaves the event retryable. The database has to arbitrate the competing inserts; an application-level lookup followed by a write leaves a race. &lt;a href="https://www.postgresql.org/docs/16/index-unique-checks.html" rel="noopener noreferrer"&gt;PostgreSQL's notes on uniqueness checks&lt;/a&gt; explain why the conflict check belongs inside the insertion.&lt;/p&gt;

&lt;p&gt;The transaction boundary matters. Commit "processed" first, crash before doing the work, and the next delivery gets discarded forever. Move the record to the end without protecting the writes, and the duplicates come back.&lt;/p&gt;

&lt;p&gt;An external charge or email crosses another boundary. Write the outgoing intent in the same database transaction with a &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html" rel="noopener noreferrer"&gt;transactional outbox&lt;/a&gt;; its worker can still deliver twice. Where the recipient supports it, retries need a stable operation key under that provider's &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;idempotency contract&lt;/a&gt;, retention window included. A local unique row cannot enforce uniqueness inside somebody else's service.&lt;/p&gt;

&lt;p&gt;For a projection of current provider state, I also prefer rereading the provider to blindly applying an old notification. &lt;a href="https://docs.stripe.com/webhooks#event-ordering" rel="noopener noreferrer"&gt;Stripe does not guarantee event delivery order&lt;/a&gt;. Concurrent refreshes still need serialization, or a version check before writing locally.&lt;/p&gt;

&lt;p&gt;The test I would ask for: deliver the same event concurrently, then inspect the actual effects. If the operation should create one record and send one email, count both. Then interrupt processing around the commit boundary and retry. Those are two different failure modes.&lt;/p&gt;

&lt;p&gt;My bias in that conversation was out in the open: I said I come from the development world, and that I am probably biased toward solving this in the database.&lt;/p&gt;

&lt;p&gt;I had arrived at a unique constraint again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Click two: green all the way down
&lt;/h2&gt;

&lt;p&gt;Under my &lt;a href="https://dev.to/pierrelaurentmedori/your-agents-memory-is-a-liability-track-state-not-history-le7"&gt;piece on tracking state instead of an agent's history&lt;/a&gt;, &lt;a href="https://dev.to/mudassirworks/comment/3efnp"&gt;Mudassir Khan described&lt;/a&gt; a webhook that confirmed delivery while the downstream consumer silently dropped messages on a schema mismatch. Clean delivery log, no data. The loss only showed up once a reconciliation step compared inbound and stored counts. The question that came with it was practical: can you wire reconciliation into an existing pipeline without a rewrite?&lt;/p&gt;

&lt;p&gt;My answer was an independent second reader. A read-only job on its own schedule, reading the system of record and comparing what exists with what should exist. The pipeline keeps running; the reader starts exposing discrepancies.&lt;/p&gt;

&lt;p&gt;A read-back inside the run shares the run's blind spots: same cache, same credentials, same definition of success. An independent reconciler derives the expected records from a durable source and reads the persisted results through a path chosen on purpose. A separate schedule is not enough on its own: the reader has to stay clear of the assumptions that produced the false success. What it produces is a discrepancy report, not one more claim that the run completed.&lt;/p&gt;

&lt;p&gt;It is the reconciliation cron that microservices impose on you the day you give up distributed transactions. You need a way to discover that one side advanced and the other did not. The cron exists because nobody gets to infer agreement from their own successful request.&lt;/p&gt;

&lt;p&gt;Counting is a good first check, and it has two holes. Matching totals can hide missing records offset by duplicates. Matching identities can hide objects with nothing inside. I keep one in a test app: a French draft with a title, a slug and zero paragraphs, left by a June translation run I remember as green. A count would have passed it; the draft exists. The reader needs a content invariant per object type on top of the count. In my case, at least one paragraph.&lt;/p&gt;

&lt;p&gt;Upstream, a schema rejection should land in a dead-letter queue as a recoverable record, not in nothing. Once a fixed batch of unique messages has finished processing, one outcome per message, the accounting is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inbound = stored + dead-lettered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While work is pending, that category belongs in the equation too. Count delivery attempts on one side and unique records on the other, and the equation means nothing. Dead-lettered messages also need an owner and a recovery path: storing the rejection does not repair the operation. The reconciler is the backstop, not the fix.&lt;/p&gt;

&lt;p&gt;What struck me was how little of this answer depended on agents. I had been asked about a modern automation pipeline, and I had described a reconciliation job I would recognize in an older integration system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Click three: the diagram with a new name
&lt;/h2&gt;

&lt;p&gt;Then came the graph engineering discussion.&lt;/p&gt;

&lt;p&gt;The useful part of Tae's comment was the shift in what the graph represents. Once I stopped thinking about relationships between pieces of knowledge, I could see the execution structure: which work can start, what it depends on, and what happens when a branch fails.&lt;/p&gt;

&lt;p&gt;In the sense used in that discussion, graph engineering makes an agent workflow's control flow explicit and inspectable. Nodes are steps; edges and conditions describe the allowed transitions, including how parallel work rejoins. That structure gives the surrounding software places to enforce state contracts and failure handling, even when a node calls a model whose answer varies.&lt;/p&gt;

&lt;p&gt;Tae's comment ended on the case I would test first: a fan-out where one node fails silently. Say a review workflow starts two checks and combines their results. One check returns; the other times out. What lets the join proceed? Does it expose the missing result, retry that branch, or mark the review incomplete? A diagram that leaves those decisions out has drawn the happy path, not the workflow.&lt;/p&gt;

&lt;p&gt;I know those questions from service orchestration. Putting a model inside one of the boxes makes the box more interesting. It also gives me more reasons to care about what the arrows permit.&lt;/p&gt;

&lt;p&gt;The model can help choose a next step, but that choice still needs a place in an execution contract. Otherwise the diagram describes what usually happens, and the actual control flow lives somewhere in a conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The delay before recognition
&lt;/h2&gt;

&lt;p&gt;Across those threads, the recurring problem was that each participant held only part of the evidence. A worker knew it had received an event. A sender knew its request had been acknowledged. A coordinator had a result from one branch. None of them held the outcome of the whole operation.&lt;/p&gt;

&lt;p&gt;Agents add a particularly persuasive participant to that arrangement. It can explain why the task succeeded in a paragraph that reads better than the error message of the component that failed. I find it easy to give that explanation more weight than it has earned.&lt;/p&gt;

&lt;p&gt;The analogy has a limit. A transaction can prevent a duplicate write; it cannot tell you the content deserved to be written. A graph can make a decision inspectable, not correct. The old mechanisms contain some of the failures. Judging the model is still extra work.&lt;/p&gt;

&lt;p&gt;In &lt;em&gt;Kaamelott&lt;/em&gt;, a French TV comedy set at King Arthur's court, Perceval falls back on "C'est pas faux", roughly "can't say that's wrong", whenever a word escapes him. I recognize something of myself there. Sometimes I understand the mechanism before I understand the new name. Sometimes the new name delays the recognition.&lt;/p&gt;

&lt;p&gt;That is the part of my own comment I keep coming back to. Fifteen years did not spare me the day of thinking. They gave me somewhere useful to land afterward.&lt;/p&gt;

&lt;p&gt;I would just like to hear the click a little earlier.&lt;/p&gt;

&lt;p&gt;Which old mechanism did you last rediscover under a new name? Genuinely curious.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>career</category>
    </item>
    <item>
      <title>"Too many levels of symbolic links" with no symlink in sight: autofs, a bind mount and a cron container</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:54:39 +0000</pubDate>
      <link>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</link>
      <guid>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</guid>
      <description>&lt;p&gt;In September, our PHP crons started failing with an error that sounded specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Too many levels of symbolic links
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is ELOOP, the error a symlink loop gives you, so the first move was to look for a loop on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. There was no symlink on that path. Nothing to untangle.&lt;/p&gt;

&lt;p&gt;Paths and component names below are generic examples, not ours.&lt;/p&gt;

&lt;p&gt;By the time we looked again, on September 10, the file was readable. The autofs service had just restarted, and the same file answered on &lt;code&gt;/opt/code/app-a/task.php&lt;/code&gt; and on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. That left a question the successful &lt;code&gt;ls&lt;/code&gt; could not answer: what had to work for the second path to reach the first?&lt;/p&gt;

&lt;p&gt;For context: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and these crons run its background jobs. The short version of what follows: the code was already bind-mounted into the container, the paths the crons used still went through autofs, so we mounted the code directly at those paths and took them out of the automount map. If ELOOP sends you hunting for symlinks that are not there, look at your automounts, and at what the container's mount namespace can actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autofs layer inside Docker
&lt;/h2&gt;

&lt;p&gt;The cron container's Compose configuration already mounted the application code from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host /srv/code/app-a -&amp;gt; container /opt/code/app-a
host /srv/code/app-b -&amp;gt; container /opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application expects &lt;code&gt;/srv/apps/app-a&lt;/code&gt; and &lt;code&gt;/srv/apps/app-b&lt;/code&gt;. The image supplied those two paths through an autofs direct map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/auto.master
/- /etc/auto.apps --ghost,--timeout=30

# Two entries in /etc/auto.apps
/srv/apps/app-a -fstype=bind :/opt/code/app-a
/srv/apps/app-b -fstype=bind :/opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any access to either path could trigger the automounter. The other entries in that map were NFS shares, managed by the same daemon.&lt;/p&gt;

&lt;p&gt;Two layers of mounting for code that was already local. Opening a PHP file depended on the service that manages our network mounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an automount can return ELOOP
&lt;/h2&gt;

&lt;p&gt;The error name is narrower than the mechanism behind it. In Linux v6.1, &lt;a href="https://github.com/torvalds/linux/blob/v6.1/fs/namei.c#L1340-L1363" rel="noopener noreferrer"&gt;&lt;code&gt;follow_automount()&lt;/code&gt;&lt;/a&gt; increments &lt;code&gt;nd-&amp;gt;total_link_count&lt;/code&gt;, the same counter symlink traversal spends, and returns &lt;code&gt;-ELOOP&lt;/code&gt; once it reaches &lt;code&gt;MAXSYMLINKS&lt;/code&gt;, which is 40. During a path lookup, every automount crossed counts against the symlink limit. No symlink required.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.kernel.org/filesystems/autofs.html#autofs-name-spaces-and-shared-mounts" rel="noopener noreferrer"&gt;autofs documentation&lt;/a&gt; describes the other half. When an autofs filesystem is visible in several places and the mounts created by its daemon do not propagate to the others, access from those other places "will likely result in the ELOOP error". The caller keeps meeting a trigger and never sees the mount it is waiting for.&lt;/p&gt;

&lt;p&gt;That is how you get ELOOP without a symlink. Which of the two our incident took, we cannot say: the investigation tied the failure to an NFS incident and an unhealthy automounter, and we did not capture a kernel trace. What the configuration did show is the dependency itself, and it had no reason to exist, whatever upset the daemon first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix stayed in configuration
&lt;/h2&gt;

&lt;p&gt;On September 10 we added direct Docker binds at the paths the crons actually use. The relevant Compose fragment:&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-a&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-a&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-b&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also gave the cron container its own copy of the automount map, with those two entries removed. It takes both changes: leave the keys in the map and autofs installs its triggers over the direct mounts again.&lt;/p&gt;

&lt;p&gt;One detail of our image: the map path, shown here as &lt;code&gt;/etc/auto.apps&lt;/code&gt;, is a symlink to the real map file. The Compose override mounts the cron-specific map over that target. The web containers keep the map they had.&lt;/p&gt;

&lt;p&gt;The application teams maintain and deploy that code. This was an operations fix, so it stayed in the mount configuration and the application paths did not move. No PHP patch, no local checkout drifting away from what the application teams deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the dependency is gone
&lt;/h2&gt;

&lt;p&gt;Roy from &lt;em&gt;The IT Crowd&lt;/em&gt; has a suggestion: "Have you tried turning it off and on again?" We already had a readable file after a restart. The useful test was whether it still needed the automounter.&lt;/p&gt;

&lt;p&gt;Inside the cron container, check each mount on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-a
findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-b
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^/srv/apps/app-(a|b)[[:space:]]'&lt;/span&gt; /etc/auto.apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mounts should resolve to the host-backed code, with no autofs trigger underneath. The count should be &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;grep&lt;/code&gt; exits with status 1 when nothing matches. If mounts are stacked, read &lt;code&gt;/proc/self/mountinfo&lt;/code&gt; as well: the filesystem on top hides what sits below it.&lt;/p&gt;

&lt;p&gt;Then check the cron runs after the deployment. The jobs have to actually run, and their new logs have to be free of ELOOP. Silence from a job that never started proves nothing.&lt;/p&gt;

&lt;p&gt;The deployment was confirmed working on September 10. One check we have not run yet: in a maintenance window, with the NFS-dependent jobs paused, stop autofs inside the cron container, read the PHP entry point, then restart autofs and confirm it is active. The direct bind should stay readable. That tests the dependency removal, not an NFS hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance we kept
&lt;/h2&gt;

&lt;p&gt;We now have two maps, and any change to their shared NFS entries has to land in both. Removing a runtime dependency left us a configuration chore.&lt;/p&gt;

&lt;p&gt;The code was local all along. We changed how the crons reach it, and opening a local PHP file no longer needs the automounter to cooperate.&lt;/p&gt;

&lt;p&gt;Have you met ELOOP with no symlink in sight? What was underneath on your side: autofs, a bind mount, something stranger? Genuinely curious.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How do you debug something that is allowed to be wrong?</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Fri, 11 Sep 2026 12:03:38 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/how-do-you-debug-something-that-is-allowed-to-be-wrong-5681</link>
      <guid>https://dev.to/pierrelaurentmedori/how-do-you-debug-something-that-is-allowed-to-be-wrong-5681</guid>
      <description>&lt;p&gt;Yesterday afternoon a runtime I wrote created 70 paragraphs on the same draft article. It was supposed to create one. The runtime was an experiment in explicit state, the kind where the model gets the task spec, a small JSON, the last observation, and nothing else; it had a commit rule I was proud of: nothing counts as done until a read-back confirms it. After every paragraph it read the draft back, found no paragraph, and asked the model for another one. Seventy times.&lt;/p&gt;

&lt;p&gt;My first suspect was the model. It was the last thing that had produced output, and blaming it costs nothing. Then I looked at the response sizes of the read-backs: 742 bytes, 742, 742, twenty-five times in a row, then 131,991 bytes at once. The paragraph list had been served from a cache with a 60-second lifetime. The model had done exactly what a correct runtime told it to do, and the runtime had been lied to by its own read path. Not the model. Not even the rule. The order of two reads.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and I operate &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;a production MCP&lt;/a&gt; server plus a few scheduled agents of my own. In the &lt;a href="https://dev.to/pierrelaurentmedori/your-mcp-write-returned-200-did-the-right-thing-actually-happen-38n0"&gt;write-safety piece&lt;/a&gt; I asked whether a 200 meant the right thing happened; in &lt;a href="https://dev.to/pierrelaurentmedori/determinism-is-a-feature-3kb9"&gt;the determinism piece&lt;/a&gt; I argued that verification is the job now. This one is about what comes after the verification says no. A system that is allowed to be wrong cannot be debugged by asking whether it was wrong. It has to be debugged by three questions, in this order: what was it allowed to get wrong, which layer got it wrong, and how long did the error live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write down what it is allowed to get wrong
&lt;/h2&gt;

&lt;p&gt;I had never written this down. Doing it took an evening and changed what I look at first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;block&lt;/th&gt;
&lt;th&gt;output&lt;/th&gt;
&lt;th&gt;tolerated&lt;/th&gt;
&lt;th&gt;never allowed&lt;/th&gt;
&lt;th&gt;detected today by&lt;/th&gt;
&lt;th&gt;when it does not run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MCP server, as its operator&lt;/td&gt;
&lt;td&gt;tool results with a policy envelope, one usage row per call&lt;/td&gt;
&lt;td&gt;latency; a read served from the 60-second cache when no write preceded it; a client choosing the wrong tool; 429 above 1,000 calls a minute&lt;/td&gt;
&lt;td&gt;a 2xx for a write that did not persist; a stale read right after a write by the same token; a tool silently missing from a client's list; a push nobody scheduled&lt;/td&gt;
&lt;td&gt;Sentry on unhandled exceptions; status and error code in the usage row; one guard test against schema shapes that hide tools&lt;/td&gt;
&lt;td&gt;loud: 421, 503, 429. Silent: the server is up and its cache is wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;content agents, Claude Code sessions writing to seven blogs&lt;/td&gt;
&lt;td&gt;drafts in &lt;code&gt;nonpret&lt;/code&gt;, patches, JSON-LD&lt;/td&gt;
&lt;td&gt;a clumsy sentence; a meta title to redo&lt;/td&gt;
&lt;td&gt;a live article patched without a diff; a create that defaults to &lt;code&gt;published&lt;/code&gt;; an em-dash in a slug; invalid JSON-LD on a live page; an empty body reported as done&lt;/td&gt;
&lt;td&gt;my approval in the CMS; a diff before and after when a patch touches live pages; JSON-LD validation after injection&lt;/td&gt;
&lt;td&gt;nothing ships. The risk is the half-written object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scheduled tasks: noon ops review, two code reviews, glossary sentinel&lt;/td&gt;
&lt;td&gt;a DM to one colleague; two morning reports; a Monday report on 170 pages&lt;/td&gt;
&lt;td&gt;a false positive dismissed in a minute; a missed run if the next one says so&lt;/td&gt;
&lt;td&gt;"all green" over a broken page; a number in the DM that is not in Nagios; a skipped run with no trace&lt;/td&gt;
&lt;td&gt;22 negative test cases and a positive control on the sentinel; the colleague reading the DM; nothing counts the runs&lt;/td&gt;
&lt;td&gt;silence, indistinguishable from "nothing to report"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two admissions fell out of the table. First, I have no fixed review routine: I read what an agent produced when it produced it, or when something surfaces, with no schedule and no written verdicts. Second, half of the "never allowed" cells had no detector the day I wrote them. Write it down, or every output is a bug and a feature at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribute before you debug
&lt;/h2&gt;

&lt;p&gt;Here are the incidents I could date on my own systems since April, coded by the layer that was actually wrong. The list is what I could date, not a sample, and the split describes this list, not my fleet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;when&lt;/th&gt;
&lt;th&gt;what&lt;/th&gt;
&lt;th&gt;layer&lt;/th&gt;
&lt;th&gt;first suspect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;April 10 to 14&lt;/td&gt;
&lt;td&gt;access tokens set to 300 seconds "just in case"; every client logged out every five minutes&lt;/td&gt;
&lt;td&gt;configuration&lt;/td&gt;
&lt;td&gt;the clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April 8 to August 5&lt;/td&gt;
&lt;td&gt;a per-app session cap counted sessions for an hour after their last call; clients that never sent the protocol DELETE starved it and legitimate traffic got 429s&lt;/td&gt;
&lt;td&gt;harness&lt;/td&gt;
&lt;td&gt;the clients, again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June&lt;/td&gt;
&lt;td&gt;agents patched translations on seven live blogs with no draft step; a diff afterwards found 46 damaged paragraphs (from my notes; the artefacts are gone)&lt;/td&gt;
&lt;td&gt;harness&lt;/td&gt;
&lt;td&gt;the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June&lt;/td&gt;
&lt;td&gt;a translation pipeline reported green on a French draft with a title and no body; I remember the run as green, its history is purged, the draft is still empty&lt;/td&gt;
&lt;td&gt;harness&lt;/td&gt;
&lt;td&gt;the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June 5 to 22&lt;/td&gt;
&lt;td&gt;a schema change made for a directory review put a &lt;code&gt;oneOf&lt;/code&gt; at the root of one tool; clients that drop such tools stopped seeing it for 17 days&lt;/td&gt;
&lt;td&gt;tool contract&lt;/td&gt;
&lt;td&gt;the clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July 29 and 31&lt;/td&gt;
&lt;td&gt;an unbounded Redis pool hit 10,000 clients, 3,650 connections rejected, every worker locked out; two days later one stream read per session filled the 200-slot pool&lt;/td&gt;
&lt;td&gt;infrastructure&lt;/td&gt;
&lt;td&gt;the load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 5&lt;/td&gt;
&lt;td&gt;a delete succeeded and the immediate read-back returned the object; fixed on August 10 by keying the read-after-write bypass on the token instead of a per-session variable&lt;/td&gt;
&lt;td&gt;cache&lt;/td&gt;
&lt;td&gt;the model, for a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August&lt;/td&gt;
&lt;td&gt;the first version of my llms.txt annex read 8 days of logs and reported them as 121&lt;/td&gt;
&lt;td&gt;the debugger&lt;/td&gt;
&lt;td&gt;the logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;September 3&lt;/td&gt;
&lt;td&gt;a state runtime created 70 paragraphs because its second read went through the cache&lt;/td&gt;
&lt;td&gt;cache, then the runtime's read order&lt;/td&gt;
&lt;td&gt;the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June 3 to September 2&lt;/td&gt;
&lt;td&gt;125 calls to 34 tools that do not exist, including &lt;code&gt;GBContent.getItems(sectionId, opts, onOk, onErr)&lt;/code&gt;, from 5 apps&lt;/td&gt;
&lt;td&gt;the model, with a harness that let it retry&lt;/td&gt;
&lt;td&gt;nobody, until I counted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten lines. The model is the direct author of one of them, the invented tool names, and even there a client let it retry the same name until it gave up. My first suspect was the model in four of the ten. The reflex was wrong four times; the model, once.&lt;/p&gt;

&lt;p&gt;The rule that fell out of it: attribute before you touch the prompt. Data and credentials first (was the input what you think it was), then tool and cache (did the read-back read the world or a copy of it), then harness and scheduler (did the loop do what the loop does, retry, truncate, reorder), then the model, then the human reading the output. Every layer above the model is deterministic and checkable in minutes. The prompt is where you go last, because it is the only layer where a fix cannot be verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transcript is a witness, not evidence
&lt;/h2&gt;

&lt;p&gt;When an agent has been wrong, the transcript is the first thing you open and the last thing you should trust. It records what the model saw and what it said. It does not record what happened. Ask the transcript "what did you call, with which arguments", never "did it work".&lt;/p&gt;

&lt;p&gt;On September 3, 2026, I measured what our own read path answers, 50 cycles on a test app, every article created as a draft and deleted after. Create, read, delete, read: the immediate read after the delete still returned the object 3 times out of 50, after the August fix. Once in 50, the immediate read after a create did not find the new article at all. Then the sequence an agent actually produces, create, read, read, delete, list, read: the deleted object came back 50 times out of 50, and stayed for a median 61.1 seconds, maximum 61.2. The server bypasses the cache for exactly one read, the one that follows the write; a list in between resets it. A transcript of that agent would show a delete with a 200 and a read that returned the article. Both true. Neither what happened.&lt;/p&gt;

&lt;p&gt;The same cycles confirmed three things I had logged in August as tickets. A delete returns only the policy envelope, no &lt;code&gt;deleted&lt;/code&gt;, no &lt;code&gt;id&lt;/code&gt;, no &lt;code&gt;status&lt;/code&gt;; the transcript literally cannot show what was deleted. &lt;code&gt;cms_create_article&lt;/code&gt; still defaults to &lt;code&gt;published&lt;/code&gt;, so a create without an explicit status is a publication. And the verification hints on a create list &lt;code&gt;cms_get_article&lt;/code&gt;, &lt;code&gt;cms_get_article_paragraph&lt;/code&gt; and &lt;code&gt;cms_get_event&lt;/code&gt;, which are the first three read tools of the family in alphabetical order, while the tool plan points its verification call at &lt;code&gt;cms_list_cms_sections&lt;/code&gt;. Follow the plan to the letter and you verify an article by listing sections.&lt;/p&gt;

&lt;p&gt;None of this is exotic, and that is the point. The June agents that damaged 46 paragraphs were not lying. They were reporting. Nobody had asked the state, only the transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure how long the error lived
&lt;/h2&gt;

&lt;p&gt;Error rates are the number everyone asks for and the least useful one I have. What changes behaviour is time to detection, per incident, per channel.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;error&lt;/th&gt;
&lt;th&gt;lived&lt;/th&gt;
&lt;th&gt;caught by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stale read after delete, list in between&lt;/td&gt;
&lt;td&gt;61 seconds, every time&lt;/td&gt;
&lt;td&gt;a script, September 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the annex that read 8 days as 121&lt;/td&gt;
&lt;td&gt;hours&lt;/td&gt;
&lt;td&gt;me, because a number looked too round&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the 17-day invisible tool&lt;/td&gt;
&lt;td&gt;17 days&lt;/td&gt;
&lt;td&gt;a guard test written on June 22; who noticed first is not in the log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429s on legitimate traffic from the session cap&lt;/td&gt;
&lt;td&gt;until August 5; the cap dates from April 9, the first victim is undated&lt;/td&gt;
&lt;td&gt;logs, August 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the empty French draft&lt;/td&gt;
&lt;td&gt;since June, still there&lt;/td&gt;
&lt;td&gt;nothing; I keep it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ops review not running on August 8, 9, 15, 16&lt;/td&gt;
&lt;td&gt;26 days for the first one&lt;/td&gt;
&lt;td&gt;me, counting transcripts on September 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;125 calls to tools that do not exist&lt;/td&gt;
&lt;td&gt;92 days&lt;/td&gt;
&lt;td&gt;a CSV export, September 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows have no detector at all, only a person who happened to count. The four missing ops reviews were two closed-laptop weekends; nothing said so, because a run that does not start writes nothing, and nothing is the same colour as green. The invented tool names errored 125 times in front of a server that logs every call, and the log was never read for that question. Time to detection is the metric that tells you which of your checks are actually running. We do not lower the error rate. We shorten how long an error lives.&lt;/p&gt;

&lt;p&gt;What survives at three weeks is the other half of that measure. For my scheduled runs: the full transcript, locally, with token counts and tool calls, so I can answer "what did it do, in what order" for any day since August. For the server: one row per call with keys and sizes, never values, and no fingerprint of which agent made it. For the June pipeline: nothing; the execution history is purged, and my memory of a green run is the only witness. Three systems, three answers to the same forensic question, and none of them can answer "was it the right action". Only the read-back can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugger is also non-deterministic
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part of debugging a system that is allowed to be wrong is that the thing you debug it with is allowed to be wrong too.&lt;/p&gt;

&lt;p&gt;My llms.txt annex read the wrong window. My first E2 verifier divided every French body by an English length of one and failed six correct drafts. And the glossary sentinel, before it was a script, was a cloud routine created on August 12 that rewrote its own checker at every run: a verifier that is a slightly different program each week cannot be debugged, because there is nothing stable to debug. It ran twice, both green. I replaced it on August 17, on that design argument alone, with a frozen script, 22 negative test cases, a positive control, under version control. Four reports since, all green on 170 pages, 89 to 166 seconds each. The state is the sitemap. The validator is the test file.&lt;/p&gt;

&lt;p&gt;Freeze the verifier first. It is the only component whose correctness you can actually prove, and the only one nobody wants to write. On one article, 20 identical translation calls at temperature 0 on September 3 gave one distinct output; the eight deterministic checks I run on a translation caught all six faults I injected, and one of them, an altered URL, was caught by exactly one check. A judge would have shrugged. The status code would have said 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not built
&lt;/h2&gt;

&lt;p&gt;Not shipped on our server, still: a heartbeat on scheduled runs; a token fingerprint in the usage row, so that "which agent" has an answer; an outcome flag next to the status code, so that a 200 that did the wrong thing shows on a dashboard; idempotency keys, version-bound writes, a plan-then-commit object; the three tickets above, open since August 5, unchanged at our August 28 commit. What is built: the error budget table, the incident registry, the frozen sentinel, and a read path I now know to measure rather than trust.&lt;/p&gt;

&lt;p&gt;When your agent was wrong last time, what did you look at first, the prompt or the data? Genuinely curious which way the reflex goes, and how often the model turned out not to be the culprit. I will answer with the registry and the read-back numbers, cycle by cycle.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>observability</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Fourteen years of blog posts, seven languages, one laptop: an open-weight model did our hreflang backfill</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:37:50 +0000</pubDate>
      <link>https://dev.to/goodbarber/fourteen-years-of-blog-posts-seven-languages-one-laptop-an-open-weight-model-did-our-hreflang-kpo</link>
      <guid>https://dev.to/goodbarber/fourteen-years-of-blog-posts-seven-languages-one-laptop-an-open-weight-model-did-our-hreflang-kpo</guid>
      <description>&lt;p&gt;In May I had 5,892 blog posts spread over seven hosts, the oldest dated November 7, 2011, and not one field anywhere that said which post was the translation of which. Same company, same blog: French on fr.goodbarber.com, English on www, then es, it, pt, de and nl on their own subdomains. In our CMS each language blog is a separate site, and for fourteen years a translation was published as a new, unrelated article. Google's hreflang wants, for every article, the full list of its language versions, and it wants the list on every one of them. We had the articles. We did not have the list.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, a no-code app platform headquartered in Ajaccio, Corsica. I have no religion about where a model runs. This post is about one job where a two-year-old open-weight model on a laptop was the right tool, with the numbers to show it, and about why we are hosting a Hacktoberfest Fest on exactly this subject on October 21.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a judgment call, not a join
&lt;/h2&gt;

&lt;p&gt;Every obvious key fails. The number at the end of each URL (&lt;code&gt;-a1332&lt;/code&gt; in French, &lt;code&gt;-a1486&lt;/code&gt; in English for the same article) is a per-blog counter. Publication dates are days apart for a translation, sometimes months. Titles are translated freely, and the monthly "What's new at GoodBarber" post carries the same title every month in every language. Slug overlap works when the translator kept the English words and fails precisely when they did their job.&lt;/p&gt;

&lt;p&gt;A person would read the summary of the French article, read the summaries of the English candidates, and decide. That is a language model's job. The question was which one, and where.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recipe: four scripts, one afternoon of GPU time
&lt;/h2&gt;

&lt;p&gt;Hardware: a MacBook Pro with an M3 Max and 48 GB of memory. Runtime: &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. Two models, both open weights: &lt;a href="https://ollama.com/library/gemma2" rel="noopener noreferrer"&gt;gemma2:27b&lt;/a&gt;, Google's June 2024 release, 15 GB on disk at the default 4-bit quantization, under the &lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;Gemma terms&lt;/a&gt;; and &lt;a href="https://ollama.com/library/bge-m3" rel="noopener noreferrer"&gt;bge-m3&lt;/a&gt;, BAAI's multilingual embedding model, MIT licensed, 1.2 GB, 1,024 dimensions. Around 600 lines of Python with httpx, no framework, no vector database.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Crawl, deterministically
&lt;/h3&gt;

&lt;p&gt;One script dumps every article of every language blog into a JSONL file, one line per article, from the CMS listing API. It flushes each page as it lands and skips ids already on disk, so it survives being interrupted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;blog&lt;/th&gt;
&lt;th&gt;articles&lt;/th&gt;
&lt;th&gt;first post&lt;/th&gt;
&lt;th&gt;last post&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;2011-11-07&lt;/td&gt;
&lt;td&gt;2026-04-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;en&lt;/td&gt;
&lt;td&gt;1,083&lt;/td&gt;
&lt;td&gt;2011-11-07&lt;/td&gt;
&lt;td&gt;2026-04-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;893&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;892&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;778&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;td&gt;2013-11-25&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nl&lt;/td&gt;
&lt;td&gt;478&lt;/td&gt;
&lt;td&gt;2013-11-25&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,892 articles, crawled on the morning of May 4, 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Narrow before you ask
&lt;/h3&gt;

&lt;p&gt;French is the pivot: it is the source language of most of our posts and the largest blog. For each French article and each of the six other languages, the candidates are the articles of that language published within 90 days of the French one, closest first, twenty at most. The model never sees the 1,083 English posts. It sees at most twenty summaries and picks one, or none.&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="n"&gt;DATE_WINDOW_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="n"&gt;TOP_K_CANDIDATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="n"&gt;MIN_CONFIDENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where most of the accuracy comes from, and it costs nothing. A retrieval step does not need to be clever; it needs to make the question small.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Ask one small question, in JSON, at temperature zero
&lt;/h3&gt;

&lt;p&gt;The prompt, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match translations of blog articles between languages.

RULES:
- A match means the SAME article translated - same specific content, same arguments,
  same examples - not just the same broad topic or shared keywords.
- Two articles can share a keyword (e.g. 'no-code', 'GoodBarber', 'app') and still be
  DIFFERENT articles. Reject those.
- Compare the FULL summary, not just the title. Look for the same specific subject,
  same angle, same takeaways.
- If you are not sure it's the same exact article, set index=null.
- Return high confidence (&amp;gt;0.8) only when the summary clearly describes the same content.

SOURCE (fr):
title: ...
summary: ...
date: ...

CANDIDATES (en):
[0] title: ...
    summary: ...
    date: ...
[1] ...

Reply JSON only: {"index": &amp;lt;int&amp;gt;|null, "confidence": &amp;lt;0..1&amp;gt;}. Default to index=null when unsure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call:&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="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&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;model&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;gemma2:27b&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;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&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;json&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;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&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 rules around it. An answer under 0.7 confidence is discarded. An article can belong to one row only, so a matched URL leaves the candidate pool for everyone else. And after every pivot the CSV is rewritten atomically, with a sidecar file recording which (pivot, language) pairs were already attempted, so a Ctrl-C or a rerun never pays for a prompt twice.&lt;/p&gt;

&lt;p&gt;That sidecar exists because I reran the thing several times while changing the prompt and the thresholds. With a hosted API, each rerun is a line on a bill and a rate limit to negotiate. Here it was a keystroke.&lt;/p&gt;

&lt;p&gt;I measured the cost of one prompt again today, September 9, on the same laptop, with the same code and the same model: the French "GoodBarber vs Glide" article against its thirteen English candidates within the window, 1,342 prompt tokens, 16 tokens out. Cold, 24.7 seconds, of which 11.2 to load the model. Warm, 2.3 seconds. Both runs answered &lt;code&gt;{"index": 2, "confidence": 0.95}&lt;/code&gt;, and index 2 is the right article. About 6,500 (pivot, language) pairs at that speed is roughly four hours of GPU time. The crawl ran on May 4; the CSV was last written on May 6 at 20:49. Sent to a hosted model, those eleven million or so prompt tokens would have cost somewhere between a couple of dollars and a couple of hundred depending on the model. Money was never the argument. The meter was.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Verify with a different model
&lt;/h3&gt;

&lt;p&gt;The generator is the judge. It should not also be the reviewer. Three layers, cheapest first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic checks.&lt;/strong&gt; Host matches the column, slug has the expected shape, each URL appears once in its column, and the publication dates within a row span 90 days at most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slug overlap.&lt;/strong&gt; A cell whose slug shares almost no words with the French slug, in a row where the other cells share plenty, gets flagged. It is a heuristic with known false positives on well-translated slugs; it only surfaces candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second model.&lt;/strong&gt; bge-m3 embeds the title plus the first 300 characters of the summary for all 5,892 articles (an 82 MB cache on disk, once). For each row, pairwise cosine similarity between the cells. A cell whose mean similarity to its row-mates drops 0.15 below the others is an outlier; a row whose mean is under 0.55 is weak. In fix mode the script proposes, for each outlier, the article of that language closest to the centroid of the other cells, and only if it scores at least 0.70 against the centroid, at least 0.70 against the French pivot, and beats the current cell by at least 0.10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two models disagreeing is the review queue. A human reads the queue, not 5,325 cells.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;en&lt;/th&gt;
&lt;th&gt;es&lt;/th&gt;
&lt;th&gt;it&lt;/th&gt;
&lt;th&gt;pt&lt;/th&gt;
&lt;th&gt;de&lt;/th&gt;
&lt;th&gt;nl&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;French pivots&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cells filled&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;959&lt;/td&gt;
&lt;td&gt;766&lt;/td&gt;
&lt;td&gt;760&lt;/td&gt;
&lt;td&gt;688&lt;/td&gt;
&lt;td&gt;616&lt;/td&gt;
&lt;td&gt;451&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,325 of the 5,892 articles landed in a row, 90.4 percent. 347 rows are complete in seven languages, 151 more in six.&lt;/p&gt;

&lt;p&gt;The gaps are older than the model. The Spanish, Italian and Portuguese blogs opened in January 2013 and the German and Dutch ones in November 2013, and the early years published a lot of local-only content that was never translated. So the 193 French posts of 2013 yielded 227 matched cells and no complete row, which is the right answer, not a miss. From 2019 on, more than half of the French posts have all six translations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;year of the French post&lt;/th&gt;
&lt;th&gt;French posts&lt;/th&gt;
&lt;th&gt;matched cells&lt;/th&gt;
&lt;th&gt;complete rows (6 of 6)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2013&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;227&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2015&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;542&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;226&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;354&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;473&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two implementations, one list
&lt;/h2&gt;

&lt;p&gt;We had no ground truth. Nobody was going to grade 5,325 pairs by hand, and a validator written by the same person with the same assumptions grades itself. So over the same two weeks in May, a colleague, Marc, built a second implementation with nothing in common with mine: a different method, an embedding model and a nearest-neighbour index (pgvector) instead of a generator and a prompt, the same French pivot, a similarity threshold at 0.60, and a preference for the French article that shares the same thumbnail image, by hash, when one exists. No date window, no candidate list, no shared code, and no import of my CSV. Two lists, built blind. The rule was simple: when they said the same thing, we would stop.&lt;/p&gt;

&lt;p&gt;I pulled the export of the second implementation today and diffed it against my May file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its export lists 1,137 rows today; 1,051 of my 1,085 French pivots are in it.&lt;/li&gt;
&lt;li&gt;Of the 4,122 cells both pipelines filled, 4,037 are identical: 97.9 percent.&lt;/li&gt;
&lt;li&gt;85 differ. I read the first 40 by hand: in 38 the second implementation is right and mine is wrong, the other two are a toss-up. The errors cluster in three families: serial posts (the monthly "What's new", the engine revision announcements, where a 240-character summary does not carry the edition), topic pairs published in the same window (two reseller posts, two native-ads posts, which my 90-day filter served up together), and a handful where the summary simply was too short.&lt;/li&gt;
&lt;li&gt;284 cells the second implementation filled that mine had left empty, and 110 that mine filled and it leaves empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two implementations that share nothing but the corpus, landing on the same answer 98 times out of 100: that was the stop criterion, and we stopped. Since June our blog sitemaps carry the alternates, rebuilt from that export on every regeneration, best-effort: if the export is unavailable, the sitemap is generated without alternates rather than not at all. As of today, on our seven hosts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;host&lt;/th&gt;
&lt;th&gt;blog URLs in sitemaps&lt;/th&gt;
&lt;th&gt;with hreflang alternates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;www (en)&lt;/td&gt;
&lt;td&gt;1,107&lt;/td&gt;
&lt;td&gt;983&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;1,084&lt;/td&gt;
&lt;td&gt;1,022&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;945&lt;/td&gt;
&lt;td&gt;844&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;943&lt;/td&gt;
&lt;td&gt;854&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;888&lt;/td&gt;
&lt;td&gt;801&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;701&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nl&lt;/td&gt;
&lt;td&gt;561&lt;/td&gt;
&lt;td&gt;536&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,741 of 6,296 URLs, 91.2 percent. The alternates live in the sitemaps only, not in the pages' &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, which Google accepts as one of the three supported ways to declare them. One entry, as served today, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://www.goodbarber.com/blog/design-trends-2026-...-a1608/&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"fr"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fr.goodbarber.com/blog/tendances-design-2026-...-a1439/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.goodbarber.com/blog/design-trends-2026-...-a1608/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"es"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://es.goodbarber.com/blog/tendencias-de-diseno-2026-...-a1137/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"it"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://it.goodbarber.com/blog/tendenze-design-2026-...-a1102/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"pt"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://pt.goodbarber.com/blog/tendencias-de-design-2026-...-a1341/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"de"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://de.goodbarber.com/blog/designtrends-2026-...-a1441/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"nl"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://nl.goodbarber.com/blog/designtrends-2026-...-a1435/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same seven lines appear under the French, Spanish, Italian, Portuguese, German and Dutch URLs of that article in their own sitemaps, which is what makes the set reciprocal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you about open weights, after this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Task design beats model size.&lt;/strong&gt; The 90-day window and the twenty-candidate cap did more for accuracy than any model choice would have. A June 2024 model at 4-bit was enough because it was never asked to search; it was asked to compare twenty summaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeatability is a feature.&lt;/strong&gt; Temperature zero and JSON mode gave me the same answer on the same prompt today as in May. Debugging a matcher that answers differently on each run is not debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The meter changes what you build.&lt;/strong&gt; The attempts sidecar, the reruns, the fix loop: none of it would exist at a price per prompt. Zero marginal cost is not about saving money on the final run. It is about how many times you are willing to be wrong on the way there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with a second, cheaper model.&lt;/strong&gt; bge-m3 is 1.2 GB and MIT licensed. It never decides; it reviews. Two models with different failure modes are worth more than one bigger model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build it twice.&lt;/strong&gt; When there is no ground truth and no budget to make one, two implementations that share nothing, not the model, not the method, not the code, not the author, are a test suite you can afford. Where they agree, you are done. Where they disagree, you have a review queue, and 85 cells is a queue a person can read. Gemma 4 was already on the same disk in May, by the way; I ran the two-year-old one because it felt faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy was not the argument here.&lt;/strong&gt; Blog posts are public. But the crawl hit an internal API on a 192.168 address and nothing left the LAN, so if your corpus is not public, the same recipe runs unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hacktoberfest 2026 comes to Ajaccio, and we are hosting
&lt;/h2&gt;

&lt;p&gt;Hacktoberfest changed shape this year. It is now run by Major League Hacking and DEV, in partnership with DigitalOcean, and it stopped counting pull requests. The 2026 edition is 300-plus in-person Fests plus a global online event, all about building with open source AI: write your first skills.md, build an open-source agent, fine-tune an open-weight model. The tagline is "AI belongs to everyone", and after the afternoon described above I have no argument with it.&lt;/p&gt;

&lt;p&gt;GoodBarber is hosting the Ajaccio Fest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When:&lt;/strong&gt; Wednesday, October 21, 2026, 18:00 to 21:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where:&lt;/strong&gt; CampusPlex, 95 cours Napoléon, Ajaccio, in the middle of town.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Program:&lt;/strong&gt; doors at 18:00; at 18:30 a talk on building with open source and open-weight AI, why it matters and concrete ways to start; Q&amp;amp;A and open discussion at 19:15; drinks and time to talk to the people around you from 20:00; end at 21:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; the talk is in French; slides and shared resources are in English so you can keep going with the wider Hacktoberfest community afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price:&lt;/strong&gt; free. Registration on the &lt;a href="https://events.mlh.com/events/14893-hacktoberfest-meetup-ajaccio-x-goodbarber" rel="noopener noreferrer"&gt;MLH event page&lt;/a&gt; is recommended. Open to working professionals and university students.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No conference format, no pitch. We are one of the few tech companies headquartered on the island, and most developers here work alone; the point of the evening is to get them in one room on a weekday. If you have contributed to open source for years, come. If you have only been curious about where to start, come with a laptop, install Ollama and pull one model beforehand, and the recipe above fits in an evening.&lt;/p&gt;

&lt;p&gt;Fourteen years of posts got their hreflang from a 15 GB file and an afternoon. The interesting part was never the model. It was that nothing stood between me and trying.&lt;/p&gt;

</description>
      <category>hacktoberfest</category>
      <category>ai</category>
      <category>opensource</category>
      <category>seo</category>
    </item>
    <item>
      <title>Your agent's memory is a liability: track state, not history</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:04:57 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/your-agents-memory-is-a-liability-track-state-not-history-le7</link>
      <guid>https://dev.to/pierrelaurentmedori/your-agents-memory-is-a-liability-track-state-not-history-le7</guid>
      <description>&lt;p&gt;There is a French draft in one of my test apps that I keep like a fossil. Article 96924661: a title, a slug, zero paragraphs. A June translation run created it; I remember that run as green. I cannot check it: the n8n execution history from June is purged. What survives is a screenshot and the likeliest path, read off the canvas and never reproduced: the article is created before its paragraphs, so if the split step yields nothing, "Create FR paragraph" runs without error on nothing and the report node declares success. Green all the way down. The draft is still empty.&lt;/p&gt;

&lt;p&gt;As I wrote in June, in the &lt;a href="https://www.goodbarber.com/blog/how-to-automate-your-goodbarber-app-with-n8n-and-mcp-a1523/" rel="noopener noreferrer"&gt;piece on automating an app with n8n and MCP&lt;/a&gt;, "our runs burned 40,000 to 77,000 input tokens per minute, past the rate limits of an entry-tier API account, whatever the model." The survivor was not an agent: deterministic plumbing, plus one model call per article. One line of context so you know where I stand: I run engineering at GoodBarber, an app platform, and we run &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;a production MCP server&lt;/a&gt; everything below runs on systems I operate myself.&lt;/p&gt;

&lt;p&gt;Then a preprint crossed my feed at the end of August with a headline number: 16 times fewer tokens, 94 percent once you do the subtraction, if your agent keeps state instead of history. I had lived the mechanism before the paper, so I read it back. Here is the cell the number comes from, my June job replayed five ways, what our server learned the hard way, and the rule the paper does not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number is real. The cell matters more.
&lt;/h2&gt;

&lt;p&gt;The preprint is SKILL.state, &lt;a href="https://arxiv.org/abs/2608.26263" rel="noopener noreferrer"&gt;arXiv 2608.26263&lt;/a&gt;, v1 posted on August 26, 2026, by Sanket Badhe and Priyanka Tiwari at Google LLC with Jonghyun Chung at Purdue. At every step the model receives an immutable skill specification, the current structured state as JSON, and the last observation; the reasoning trace is discarded once the runtime has validated its state patch. The prompt stays roughly constant, so cumulative cost is O(T) instead of O(T²).&lt;/p&gt;

&lt;p&gt;The 94 percent is exact and it is one cell. Table 1, the authors' synthetic Warehouse environment, Gemini-3-Flash, horizon T = 100: 65,408 cumulative tokens against 1,062,387 for the stateful baseline, a 16.2x gap. Same column at T = 10: 43 percent. On the public benchmarks, InterCode CTF and τ-Bench, the reduction lands between 11 and 66 percent depending on the baseline. The gain is a horizon effect.&lt;/p&gt;

&lt;p&gt;The result that matters is elsewhere. At equal budget, roughly 1,800 prompt tokens for every runtime at T = 100, accuracy was 0.94 for the structured state, 0.52 for a capped summary, 0.22 for ReAct plus LLMLingua, 0.18 for a truncated window. The saving is a side effect. The structure is the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prefix is the bill
&lt;/h2&gt;

&lt;p&gt;On September 3, 2026, I counted our tool inventories with the count_tokens endpoint against claude-haiku-4-5. My n8n test app, 62 tools: 17,763 tokens. The larger test app, 77 tools: 23,174. The seven tools a translation needs: 2,416. One default page of &lt;code&gt;cms_list_articles&lt;/code&gt; on the small app: 16,987. The paper's mean prompt per step, all three inputs included: 1,905.&lt;/p&gt;

&lt;p&gt;A ReAct loop on that app opens every turn with nine times the paper's whole prompt in schemas alone, and each list it calls adds nine more that history never forgets. D's six-field state object counted 56 tokens; a create-and-read-back envelope, 112.&lt;/p&gt;

&lt;h2&gt;
  
  
  I replayed June in five arms
&lt;/h2&gt;

&lt;p&gt;Same job, same six English articles, same test app, September 3, 2026, claude-haiku-4-5, prepaid credits; the table is run 1, except D, the best of its six versions. Every French draft was created as &lt;code&gt;status: draft&lt;/code&gt; with a marker, then deleted. Verification is deterministic and outside the model: the five checks in the code block further down.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;what&lt;/th&gt;
&lt;th&gt;verified&lt;/th&gt;
&lt;th&gt;model calls&lt;/th&gt;
&lt;th&gt;input tokens&lt;/th&gt;
&lt;th&gt;cache read&lt;/th&gt;
&lt;th&gt;list price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;ReAct, full history, 62 tools&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;1,159,084&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$1.267&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;ReAct, full history, 7 tools&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;893,341&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$1.002&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;script plumbing, one call per article&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;7,135&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0.054&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;explicit state, best of six runtime versions&lt;/td&gt;
&lt;td&gt;4/6&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;155,249&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0.348&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;arm A with prompt caching&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;1,080,099&lt;/td&gt;
&lt;td&gt;$0.306&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A does C's job with 160 times the input tokens. B, with 55 fewer tools, still needs 125 times: trimming the inventory does not fix a loop that re-reads its own transcript. A peaked at 694,434 prompt tokens in a 60-second window and never saw a 429; the account is not where it was in June, so I will not quote a June limit. C is the June survivor, a stdlib script now that the n8n workflow no longer runs: O(1) per article, and its prompt was the paper's triplet before anyone named it.&lt;/p&gt;

&lt;p&gt;A second pass the same day reproduced A within 0.1 percent (1,158,626 input tokens) and B 13 percent lower (773,767); C used the same 7,135 tokens and E came out at the same $0.306.&lt;/p&gt;

&lt;p&gt;E, arm A with cache breakpoints: 123 uncached input tokens, 73,114 written to cache, 1,080,099 read from it, 93 percent of the prompt served from cache, a quarter of A's price. Caching makes the transcript cheap. It does not make it small, or a sufficient statistic of anything.&lt;/p&gt;

&lt;p&gt;And D, my own SKILL.state, never finished. Six runtime versions in one afternoon, 80 to 100 steps each, prompt flat between 1,400 and 2,500 tokens per step, best result 4 of 6. Each version failed somewhere new. The first schema carried ids and statuses but not the translation, so the model re-read the source at every step, 33 reads for 6 articles, and the runtime filed one French id under two sources. With the translation in state, the model invented tool arguments, &lt;code&gt;author_id&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;language&lt;/code&gt;, and the server refused 74 creates in a row. With arguments filtered against the tool schema, it re-read and re-created: 52 reads, 17 drafts for 6 sources. With the runtime owning the state machine and naming the next expected action, it created 70 paragraphs on the same drafts: after each write the runtime read the article first and the paragraph list second, so the list came from the 60-second GET cache, empty, for about 25 cycles, then 131,991 bytes at once. My commit rule was right. My read path was the cached one. With the read-back taken off the cache, 4 of 6, then a patch rejected 52 times for a string where an integer was expected. The paper reports that on small open-weight models 68 percent of failures are premature overwrites of the state; on claude-haiku-4-5 mine were the runtime's, and every one of them was a validation the runtime did not do yet. The state was constant. Sufficient is the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own transcripts are not O(T) either
&lt;/h2&gt;

&lt;p&gt;On September 3, 2026, I scanned my local Claude Code transcripts, counters only, no content: 264 files, 175 sessions with at least 30 assistant turns, 32,607 turns. Prompt per turn (input, cache creation and cache reads) climbs until a compaction, then drops: 37 drops of more than half, a median cost of 2.2 times a constant-size state, a median cache read share of 0.968. The longest session ran 2,444 turns and 1,071,111,255 prompt tokens, 6.89 times a constant state, 97 percent of it cache reads. That is &lt;a href="https://codex.danielvaughan.com/2026/08/29/skill-state-ot-agent-memory-structured-execution-state-codex-cli-long-horizon/" rel="noopener noreferrer"&gt;Daniel Vaughan's point about Codex CLI&lt;/a&gt;: O(T²/K) by chunks, not O(T). I have never noticed a decision lost to compaction and will not claim one. I can only vouch for the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server did it to itself first
&lt;/h2&gt;

&lt;p&gt;Our MCP server stopped keeping history on August 10, 2026. Since then the transport is stateless: no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, no session cap. What the server keeps per agent, keyed by the sha256 of the access token: the last ten tool names for one hour, enough to push the read that follows a write past the cache, and since August 11 a sliding counter of 1,000 requests per minute that answers 429 with &lt;code&gt;Retry-After&lt;/code&gt;. Ten names and a number.&lt;/p&gt;

&lt;p&gt;Measured on September 3, 2026, 50 cycles on a test app. A get straight after a delete: 3 stale reads out of 50, the August fix holds, not perfectly. Delete, list, get: the deleted object came back in 50 of 50 cycles and stayed a median 61.1 seconds, maximum 61.2, the 60-second GET cache doing what it was told. The bypass covers only the read right after the write; a list resets it. Three limits I read in the code rather than measured: another token never gets the bypass, nothing invalidates on write, and on an upstream failure the server serves stale cache on purpose. Read-back through a cache lies in both directions, as I wrote in the write-safety piece. It still does.&lt;/p&gt;

&lt;h2&gt;
  
  
  State is what was verified, not what the model believes
&lt;/h2&gt;

&lt;p&gt;The rule I want, and the one the paper does not have: an agent's state is not what the model reports. It is what the world confirmed. Every write tool on our server returns &lt;code&gt;_mcp_policy.verification_required: true&lt;/code&gt;, except six fire-and-forget push tools. D's runtime turned that policy into a commit rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commit rule, enforced by the script, never by the model:
  1. the model proposes a patch: {"set": {...}, "delete": [...]}
  2. the runtime validates it: an unknown key, a wrong type or any touch
     of verified_fr, and the patch is rejected, state untouched
  3. an id enters verified_fr only after cms_get_article passes every check:
     exists, status draft, at least one paragraph, no residual delimiter,
     body length within 0.6 to 1.8 of the EN body
  4. a failed read-back commits nothing; the failure is the next observation
  a 200 on cms_create_article is not a commit. The read-back is.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On one article, 20 identical calls at temperature 0 on September 3 gave one distinct output; the eight offline checks I run on a translation, a different set from the five runtime checks above, caught all six faults I injected, and a status code would have caught none. Deterministic checks, not a 200.&lt;/p&gt;

&lt;p&gt;Running that rule against our own server hit the same holes I had logged in August, and the September 3 cycles confirmed them. Ticket B: a delete returns only the policy envelope, no &lt;code&gt;deleted&lt;/code&gt;, no &lt;code&gt;id&lt;/code&gt;, no &lt;code&gt;status&lt;/code&gt;. Ticket C: &lt;code&gt;cms_create_article&lt;/code&gt; defaults to &lt;code&gt;status: published&lt;/code&gt;, unchanged since May 13. Ticket D: a create on September 3 returned &lt;code&gt;cms_get_article&lt;/code&gt;, &lt;code&gt;cms_get_article_paragraph&lt;/code&gt; and &lt;code&gt;cms_get_event&lt;/code&gt; as verification tools, the first three read tools in alphabetical order, while the tool plan points its verification call at &lt;code&gt;cms_list_cms_sections&lt;/code&gt;. Follow the plan to the letter and you verify an article by listing sections; follow the envelope and you may end up reading an event.&lt;/p&gt;

&lt;p&gt;The June draft reads differently now: an invalid state patch, created without verified, accepted by a run whose only witness is my memory. IDs, not vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  My scheduled tasks never had a memory
&lt;/h2&gt;

&lt;p&gt;My scheduled Claude Code tasks had the triplet before I read the paper. Each starts from a fresh context: a daily ops review that reads Nagios and BuildBot, daily code reviews of two workspaces, a Monday glossary sentinel. From local transcript counters over August 1 to September 2, 2026: the ops review ran 30 times, median 25 turns, 49 uncached input tokens and 1,529,904 cache-read tokens per run, about 14 tool calls a run. The two code reviews, 23 and 29 runs, read 14,689,395 and 10,637,775 cache tokens per median run. The sentinel, 3 runs, 820,312.&lt;/p&gt;

&lt;p&gt;A few dozen to a few hundred uncached tokens per run, and the cached prefix is the rest of the prompt: spec, then observation. Where is the state? In Nagios, in git, in the CMS: the system of record, never the agent. The paper's architecture, state pushed one layer down, no validator anywhere.&lt;/p&gt;

&lt;p&gt;The sentinel is the closest thing I have to a validator. A cloud routine created on August 12 ran twice, both green; I replaced it on August 17 on a design argument, not an observed drift: a routine that rewrites its script each week is a moving specification. The replacement is a frozen script with 22 negative test cases, under version control; its four reports, August 17, 25, 31 and September 2, were all green on 170 pages in 89, 139, 116 and 166 seconds. The state is the sitemap. The validator is the test file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest list
&lt;/h2&gt;

&lt;p&gt;The paper's third limitation is the one I hit last month: when the task is the trajectory itself, throwing the history away destroys the work. My llms.txt forensic needed 121 days of logs and 1,321 requests to find 26 AI-labeled hits; the facts existed only in the sequence, and no crawler state schema would have held them. There, the log is not the memory. The log is the deliverable.&lt;/p&gt;

&lt;p&gt;What the paper is not: synthetic-first, a strong horizon effect, a stateful baseline that pads the transcript with a state block rather than using LangGraph idiomatically, single-agent only. What our server has not shipped: any of it. No validator in front of any state file, no idempotency keys, no version-bound writes, no plan-then-commit object, no semantic span attributes, no token fingerprint column in the usage log. Tickets B, C and D were still open at our August 28 commit. D never finished.&lt;/p&gt;

&lt;p&gt;What is cheap, and what I am doing next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☐ Weigh your tools/list with count_tokens. Compare with 1,905.&lt;/li&gt;
&lt;li&gt;☐ Log the four usage counters per turn.&lt;/li&gt;
&lt;li&gt;☐ Schema and validator in front of the state file.&lt;/li&gt;
&lt;li&gt;☐ Commit state after read-back; deterministic checks, not a status code.&lt;/li&gt;
&lt;li&gt;☐ Freeze and version the spec.&lt;/li&gt;
&lt;li&gt;☐ When the trajectory is the deliverable, keep a ledger.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does your prompt look like at step 50, and who decided what goes in it? If you run a scheduled agent: where does its state live between runs, what validates it before the next run reads it, and how big is it in tokens? I am genuinely curious whether anyone has a schema in front of that file, or whether we are all editing JSON by hand and hoping. I will answer with token counts where I can.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>The agents in production aren't mine. Here's what their server sees</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 03 Sep 2026 11:43:19 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/the-agents-in-production-arent-mine-heres-what-their-server-sees-29f4</link>
      <guid>https://dev.to/pierrelaurentmedori/the-agents-in-production-arent-mine-heres-what-their-server-sees-29f4</guid>
      <description>&lt;p&gt;Somewhere between June 3 and September 2, 2026, an agent asked our production MCP server for a tool called &lt;code&gt;GBContent.getItems(sectionId, opts, onOk, onErr)&lt;/code&gt;. Parentheses, parameter names, callbacks, the whole JavaScript signature, sent as the name of a tool. The server has never had a tool by that name. It answered with an error, the way it answered the 124 other calls to 33 other tools that do not exist: &lt;code&gt;get_posts&lt;/code&gt;, &lt;code&gt;push_send&lt;/code&gt;, &lt;code&gt;skills/cms&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; as a tool, and a file path, &lt;code&gt;/tmp/gb_args.json&lt;/code&gt;. Five apps, 125 calls, 92 days, one hundred percent errors. Somewhere, a transcript said "let me try a different approach" and moved on.&lt;/p&gt;

&lt;p&gt;That is not in any demo, and it is not in most of the "lessons from production" threads either, because the person writing the thread usually built the agent. I run the other side. One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and we operate a &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;production MCP server&lt;/a&gt; that customers' agents call all day. My own agents run on a laptop, not in production, and the laptop closes on weekends. Everything below is counted from systems I operate myself, with the window and the date in the sentence. No customer names, no app ids, aggregates only; the volumes are rounded on purpose, the ratios are exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fleet, dated
&lt;/h2&gt;

&lt;p&gt;Here is what "agents in production" means from where I sit, as of September 2, 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what&lt;/th&gt;
&lt;th&gt;where&lt;/th&gt;
&lt;th&gt;engine&lt;/th&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;cadence&lt;/th&gt;
&lt;th&gt;who presses&lt;/th&gt;
&lt;th&gt;runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;public MCP server&lt;/td&gt;
&lt;td&gt;prod&lt;/td&gt;
&lt;td&gt;our code, customers' agents&lt;/td&gt;
&lt;td&gt;theirs, never ours&lt;/td&gt;
&lt;td&gt;continuous&lt;/td&gt;
&lt;td&gt;the customer&lt;/td&gt;
&lt;td&gt;~100k calls, 100+ apps (Jun 3–Sep 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;content agents&lt;/td&gt;
&lt;td&gt;laptop → prod CMS&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;on demand&lt;/td&gt;
&lt;td&gt;me (CMS approval)&lt;/td&gt;
&lt;td&gt;not counted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;noon ops review&lt;/td&gt;
&lt;td&gt;laptop&lt;/td&gt;
&lt;td&gt;scheduled Claude Code&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;daily 12:04&lt;/td&gt;
&lt;td&gt;Nagios/BuildBot → DM&lt;/td&gt;
&lt;td&gt;30 (Aug 1–Sep 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two code reviews&lt;/td&gt;
&lt;td&gt;laptop&lt;/td&gt;
&lt;td&gt;scheduled Claude Code&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;every morning&lt;/td&gt;
&lt;td&gt;git diff → me&lt;/td&gt;
&lt;td&gt;23 &amp;amp; 29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glossary sentinel&lt;/td&gt;
&lt;td&gt;laptop&lt;/td&gt;
&lt;td&gt;frozen Python&lt;/td&gt;
&lt;td&gt;report only&lt;/td&gt;
&lt;td&gt;Mondays&lt;/td&gt;
&lt;td&gt;170 pages → report&lt;/td&gt;
&lt;td&gt;4 green (since Aug 17)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEO checkpoints&lt;/td&gt;
&lt;td&gt;laptop&lt;/td&gt;
&lt;td&gt;one-shot tasks&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;J+21/28/42&lt;/td&gt;
&lt;td&gt;Search Console → report&lt;/td&gt;
&lt;td&gt;4 run / 7 sched.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;product AI&lt;/td&gt;
&lt;td&gt;prod&lt;/td&gt;
&lt;td&gt;RAG + back-office&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;continuous&lt;/td&gt;
&lt;td&gt;the customer&lt;/td&gt;
&lt;td&gt;dozens of apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven lines. Two are production, and the model in the loop there is the customer's or the product's, never mine; the five others run on a laptop. One line has no model in the check itself, and it used to: the glossary sentinel started life on August 12 as a cloud routine that rewrote its own checker every week, ran twice, both green, and was replaced on August 17 by a script with 22 negative test cases. Not because it drifted; because a checker that is a slightly different program each week cannot be debugged, and I stopped it before it had a chance to prove that. Four reports since, all green, 170 pages each, 89 to 166 seconds.&lt;/p&gt;

&lt;p&gt;The line that matters most is the first one, because it is the one I see least.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a server operator sees, and what it cannot
&lt;/h2&gt;

&lt;p&gt;Every tool call on our MCP server writes one row: app, tool name, client IP, status, duration, request and response sizes, and the keys of the arguments, never their values. No session id, because since August 10 the transport is stateless and there is no session. No token fingerprint, no user agent, no protocol version. That table is the whole of my visibility into other people's agents. I see the requests. I never see the conversation.&lt;/p&gt;

&lt;p&gt;From that table, June 3 to September 2, 2026, our two test apps excluded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Close to a hundred thousand calls from over a hundred apps and close to a thousand client IPs. The median day grew tenfold, from a few hundred calls in June to a couple of thousand in the first days of September.&lt;/li&gt;
&lt;li&gt;62.8 percent of calls are writes. The top tool is &lt;code&gt;cms_create_article_paragraph&lt;/code&gt;, then &lt;code&gt;cms_update_event&lt;/code&gt;. Agents are not browsing our customers' apps. They are filling them.&lt;/li&gt;
&lt;li&gt;Concentration: the top five apps hold 55.1 percent of the calls, the median app made a few dozen calls in three months, and more than one app in five was used on a single day and never again.&lt;/li&gt;
&lt;li&gt;Several thousand push calls. A push notification has no undo. Agents sent, or scheduled, thousands of them through us in 92 days, most of them through a single tool.&lt;/li&gt;
&lt;li&gt;Error rate 2.9 percent, 30 timeouts in close to a hundred thousand calls, and the 125 calls to tools that do not exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the number I actually wanted. In &lt;a href="https://dev.to/pierrelaurentmedori/your-mcp-write-returned-200-did-the-right-thing-actually-happen-38n0"&gt;the write-safety piece&lt;/a&gt; I argued that a write is not done until a read confirms it, and our server tells every agent so: each write returns &lt;code&gt;_mcp_policy.verification_required: true&lt;/code&gt; with the tools to read back with. Do they? I grouped calls into sessions by app and client IP with a ten-minute gap (a heuristic, the only key the table allows), and asked how many writes were followed by a read of the same family within 120 seconds.&lt;/p&gt;

&lt;p&gt;41.0 percent, the six fire-and-forget push tools left out of the denominator, median delay 18.9 seconds. For content, 41.5 percent. For the shop, 8.3 percent. On the stateless transport alone, August 12 to September 2: 42.2 percent.&lt;/p&gt;

&lt;p&gt;So the honest sentence is: our server asks for a read-back on every write, and on the wire, three writes out of five never get one. The policy is advice. The clients decide. And I cannot tell which client, because the table does not know: that column, the sha256 of the token the server already computes for rate limiting, is the first thing I would add, and it is not shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silence has three shapes
&lt;/h2&gt;

&lt;p&gt;My own scheduled agents run on my laptop and leave their transcripts there, so those I can count exactly. From August 1 to September 2, 2026, counters only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The noon ops review ran on 29 of 33 days. The four missing days are August 8, 9, 15 and 16: two weekends, a closed laptop. Nothing announced the absence; a run that does not start writes nothing, and nothing is the same colour as green.&lt;/li&gt;
&lt;li&gt;One code review, weekdays only, ran 23 of 23. The other, daily, 28 of 33: the same weekends, plus August 2.&lt;/li&gt;
&lt;li&gt;The sentinel ran 3 of 3 Mondays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three shapes of silence, then. Absent: the run never started, and the only witness is a gap in a folder. Green and empty: in June, a translation pipeline of mine reported success on a French draft with a title and no body; I remember the run as green, its history is purged, and the article is still empty in the test app. Unread: a report nobody opens is indistinguishable from a report nobody sent. The fix for the first shape is boring and I do not have it yet: a heartbeat on every run, absence as the alert.&lt;/p&gt;

&lt;p&gt;The debugger fails too. &lt;a href="https://dev.to/pierrelaurentmedori/llmstxt-in-the-wild-1321-requests-and-not-one-ai-assistant-came-looking-3205"&gt;My llms.txt piece&lt;/a&gt; in August was built on a forensic read of 121 days of logs; the first version of its annex read 8 days and reported it as the whole window. The logs were fine. My analysis tool was wrong, and I caught it before publishing only because a number looked too round. In June, agents patched translations on seven live blogs, live, no draft; a diff afterwards found 46 damaged paragraphs. The agents were not lying. They were reporting. Nobody had asked the right question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill nobody itemizes
&lt;/h2&gt;

&lt;p&gt;Per run, from the transcripts, medians over August:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;agent&lt;/th&gt;
&lt;th&gt;turns&lt;/th&gt;
&lt;th&gt;tool calls&lt;/th&gt;
&lt;th&gt;uncached input tokens&lt;/th&gt;
&lt;th&gt;cache-read tokens&lt;/th&gt;
&lt;th&gt;wall time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;noon ops review&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;1,529,904&lt;/td&gt;
&lt;td&gt;about 3 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;code review, workspace&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;336&lt;/td&gt;
&lt;td&gt;14,689,395&lt;/td&gt;
&lt;td&gt;about 23 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;code review, AI workspace&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;240&lt;/td&gt;
&lt;td&gt;10,637,775&lt;/td&gt;
&lt;td&gt;about 21 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glossary sentinel task (runs the script, comments the report)&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;820,312&lt;/td&gt;
&lt;td&gt;about 8 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read those columns together. A scheduled agent costs a few dozen uncached tokens a run; the rest of its prompt, one to fifteen million tokens, is the cached prefix re-read at every turn, and the code reviews re-read it a hundred times. The tokens are cheap at cache prices and the runs are on a subscription, so the bill I feel is not that one. It is the other column: the reports are read by a human. On a good week that is five minutes a day, six days a week, about half an hour, and most of it is spent confirming that nothing happened. On a bad week I have never counted.&lt;/p&gt;

&lt;p&gt;The line item no calculator shows is maintenance, and the server's git log itemizes it better than I could. Since going live on April 10, 2026: a session cap that starved legitimate traffic with 429s until August 5, then became moot on August 10 when the transport went stateless; a rate limiter promised for "v1.1" on April 9 that arrived on August 11; two Redis incidents on July 29 and 31; a schema change made for a directory review on June 5 that silently hid one tool from some clients for 17 days, because those clients drop any tool whose schema has a &lt;code&gt;oneOf&lt;/code&gt; at the root; an access token lifetime set to 300 seconds "just in case" on April 10 that logged every client out every five minutes until April 14. None of it is a model being wrong. All of it is the agent surface being a production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who holds the button
&lt;/h2&gt;

&lt;p&gt;Write down where the human is, or the demo will put one wherever it looks good.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Content agents: every draft lands as &lt;code&gt;nonpret&lt;/code&gt; on our CMS and stays there until I approve it. Nothing they write reaches a reader without a human click.&lt;/li&gt;
&lt;li&gt;The ops review and the code reviews: read-only by construction. They post to a person, never to a channel, never to a system.&lt;/li&gt;
&lt;li&gt;The server: no mandatory approval flow. The customer's client decides how much autonomy the agent gets; the server advises (&lt;code&gt;verification_required&lt;/code&gt;, a tool plan, &lt;code&gt;max_retries: 1&lt;/code&gt;) and blocks nothing except what the credential cannot reach. A strict mode that refuses a write unless a read came first exists and is off by default.&lt;/li&gt;
&lt;li&gt;Push: the six push tools return no verification hint, because there is nothing to read back. My own standing rule is &lt;code&gt;send: "at"&lt;/code&gt;, never &lt;code&gt;now&lt;/code&gt;. Thousands of pushes went through in 92 days and I have no idea how many were scheduled versus immediate. That is a column I could add, and have not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No heartbeat on my scheduled runs, and a laptop is not a scheduler. Absent looks like green.&lt;/li&gt;
&lt;li&gt;No token fingerprint in the usage table, so read-back coverage is a heuristic and per-client numbers do not exist.&lt;/li&gt;
&lt;li&gt;The usage row is inserted on the hot path, before the response goes out, and is best effort: a database hiccup loses the row silently. No alerting sits on that table. No traces, no spans.&lt;/li&gt;
&lt;li&gt;The server's read-back bypasses the cache only for the read that immediately follows a write, by the same token. On September 3, in 50 cycles on a test app, a get placed after a delete and a list still returned the deleted object 50 times out of 50, for 61 seconds. Three immediate read-backs out of 50 were stale too.&lt;/li&gt;
&lt;li&gt;Not shipped, still: idempotency keys, version-bound writes, a plan-then-commit object, semantic span attributes. Prescribed in August, unchanged at our August 28 commit.&lt;/li&gt;
&lt;li&gt;Ticket B, C, D from my August audit, all still open: a delete returns only the policy envelope; &lt;code&gt;cms_create_article&lt;/code&gt; defaults to &lt;code&gt;published&lt;/code&gt;; the verification hints on a create list &lt;code&gt;cms_get_event&lt;/code&gt; because they are the first three read tools in alphabetical order.&lt;/li&gt;
&lt;li&gt;34 tool names that do not exist were called 125 times. I did not know until I counted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this shows in a demo because a demo is one run, watched, on a happy path. Production is the runs nobody watched, and the table that says what they did.&lt;/p&gt;

&lt;p&gt;How do you know your scheduled agent ran this morning? Not that it was right: that it ran. Genuinely curious. And if you operate a tool surface for other people's agents: what share of their writes gets read back, and do you know which client is which? I will trade numbers, counting scripts included.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>devops</category>
      <category>software</category>
    </item>
    <item>
      <title>There is no free lunch, especially in app publishing</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:54:27 +0000</pubDate>
      <link>https://dev.to/goodbarber/there-is-no-free-lunch-especially-in-app-publishing-2ec8</link>
      <guid>https://dev.to/goodbarber/there-is-no-free-lunch-especially-in-app-publishing-2ec8</guid>
      <description>&lt;p&gt;2009, mobile networks class. We were studying how TCP handles packets lost over 3G: the network detects the loss, replays the packet, and the transfer survives a radio link that drops things all the time. Someone asked the obvious question: why not just make the radio link reliable in the first place? The professor's answer became the soundtrack of that semester: there is no free lunch. Reliability over 3G is not free, it is paid for in retransmissions, latency and battery. The cost never disappears. It just moves to wherever you are not looking.&lt;/p&gt;

&lt;p&gt;I now run engineering at a no-code app builder. Every pricing decision I have seen from the inside since, his sentence explains better than any business book.&lt;/p&gt;

&lt;p&gt;Here is the version of it nobody puts on a landing page: &lt;strong&gt;you can build an app for free anywhere, and you can ship one for free nowhere.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The asterisk every vendor knows by heart
&lt;/h2&gt;

&lt;p&gt;Search "free app builder". Every result promises an app for free. Every vendor behind those pages knows exactly where their free tier ends: right before the App Store and Google Play. Build for free, preview for free, then the store gates close and the pricing table appears.&lt;/p&gt;

&lt;p&gt;I will not do the pricing comparison here. We published a dated, sourced table of what each major free tier actually includes on the company blog: &lt;a href="https://www.goodbarber.com/blog/can-you-really-create-an-app-for-free-what-free-app-builders-actually-include-a1606/" rel="noopener noreferrer"&gt;Can you really create an app for free?&lt;/a&gt;. Spoiler: the pattern is identical across the market, and one competitor even admits in its own comparison that store publishing always requires a paid plan.&lt;/p&gt;

&lt;p&gt;This post is about the side of the counter you cannot see from a landing page: what the word "published" costs the vendor. Because that is where the free lunch dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "published" costs us, forever
&lt;/h2&gt;

&lt;p&gt;A built app is a file. A published app is a commitment. From our machine room, that commitment looks like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The store treadmill never stops.&lt;/strong&gt; Google Play raises its target API requirement every year. Apple killed UIWebView in 2020. The legacy push protocol died in March 2021. The Play Data safety form arrived in July 2022, Apple privacy manifests in May 2024. Each event means rebuilding and resubmitting fleets of apps through two review pipelines. Skip one deadline and apps disappear from stores. This work is invisible, recurring, and nobody can bill it to "free".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime is real hardware.&lt;/strong&gt; Our customers' apps call a backend every day. We run our own machines in racks in European datacenters. The CDN moves terabytes per day. Push delivery counts in millions of notifications per week. An engineer is on call all year; the status page is public (goodbarberstatus.com). None of this pauses when a customer pays zero.&lt;/p&gt;

&lt;p&gt;A free plan that included native publishing would mean carrying all of the above, indefinitely, for free. Nobody in this market does it. That is not stinginess. That is arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  So who pays for your free plan?
&lt;/h2&gt;

&lt;p&gt;The lunch is never free; the bill just moves. On a typical free app builder tier, you pay with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Branding.&lt;/strong&gt; Your app advertises its builder. Removing the badge is a paid feature, everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wall.&lt;/strong&gt; The upgrade prompt lands at maximum investment, minimum leverage. That timing is not an accident, it is the business model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A link instead of a listing.&lt;/strong&gt; Free "apps" are often web links. The store fees never trigger, and neither does the store presence you came for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your time.&lt;/strong&gt; The expensive one. Rebuilding elsewhere because platform one could not ship is the worst deal in no-code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And two line items belong to nobody's free plan because they are not the builder's to give: Apple charges 99 $ a year for a developer account, Google Play 25 $ once. A page that promises store publishing "for free" without those numbers is describing a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  In defense of free tiers
&lt;/h2&gt;

&lt;p&gt;Said plainly, because my employer's competitors deserve fairness: free tiers are a great deal when your goal matches their content. Testing an idea on a real phone screen. Learning how a no-code editor thinks. Shipping a link to five colleagues. For all of that, use a free plan and pay nobody, including us.&lt;/p&gt;

&lt;p&gt;The problem is never the free tier. It is the landing page that lets you believe the lunch stays free all the way to the App Store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The menu with prices on it
&lt;/h2&gt;

&lt;p&gt;GoodBarber has no free plan, and now you know the reason is structural, not commercial. Our trade is different: the full platform, free for 30 days, no credit card, including a PWA you can actually publish during the trial (&lt;a href="https://www.goodbarber.com/free-app-builder/" rel="noopener noreferrer"&gt;what free really includes&lt;/a&gt;). When it ends, nothing is charged, because we never took a card. You have seen the real product and the real prices, and you decide.&lt;/p&gt;

&lt;p&gt;No free lunch. But a menu with prices on it, and a table where you eat for 30 days before ordering.&lt;/p&gt;

&lt;p&gt;Same lesson as those 3G retransmissions: the cost never disappears, so honest design shows you where it sits. You know what to expect, and the price is not hidden. In 2009 we would have called that a well-designed protocol.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>mobile</category>
      <category>infrastructure</category>
      <category>startup</category>
    </item>
    <item>
      <title>Determinism Is a Feature</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Wed, 26 Aug 2026 08:31:29 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/determinism-is-a-feature-3kb9</link>
      <guid>https://dev.to/pierrelaurentmedori/determinism-is-a-feature-3kb9</guid>
      <description>&lt;p&gt;We spent seventy years building machines that do exactly the same thing every time. It took about three years of AI hype to start describing that property as a limitation.&lt;/p&gt;

&lt;p&gt;I keep running into the framing in threads, in pitch decks, in hallway conversations: deterministic systems as the old world, rigid, unimaginative, waiting to be disrupted by something that improvises. And every time, I want to push back with the least fashionable opinion I hold: determinism is not the boring baseline that AI liberates us from. It is the single most valuable property a production system can have, and 2026 is the year we are most eagerly trading it away.&lt;/p&gt;

&lt;p&gt;Let me be precise about what this post is not. It is not an anti-AI post. I run an engineering team, and we run agents in production: they draft, they call tools, they touch real systems every day. I like this work. The argument here is not "don't use models". The argument is that the more probabilistic components you add, the more load-bearing the deterministic parts around them become, and that almost nobody is hiring, promoting, or celebrating for the second half of that sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What determinism actually buys you
&lt;/h2&gt;

&lt;p&gt;Reproducibility is not an aesthetic preference. It is purchasing power. Walk through what "same input, same output" quietly funds:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reproducible bug is a bug that is already half fixed.&lt;/strong&gt; You can trap it, shrink it, watch it happen in a debugger. A non-reproducible bug is not a bug, it is a haunting. You don't fix hauntings, you appease them: add a retry, widen a timeout, sacrifice a sleep(500) and hope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failing test means something.&lt;/strong&gt; In a deterministic system, red means broken. The moment flakiness enters, red means "run it again", and the entire signal collapses. A suite that people rerun until it passes is not a test suite, it is a slot machine with CI minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;git bisect works.&lt;/strong&gt; Bisect is a miracle we stopped noticing: binary-search across history and the machine tells you which commit broke the world. The miracle rests entirely on replay giving the same answer twice. Bisect over a component that answers differently each run is not debugging, it is astrology with version control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A diff tells the truth.&lt;/strong&gt; Code review works because a change in behavior is legible from a change in text. The whole social contract of "I read your PR, therefore I know what it does" assumes the text determines the behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incidents can be reconstructed.&lt;/strong&gt; The 3 a.m. question is always "what exactly happened, in what order?" Every answer you can give assumes the system will tell the same story twice.&lt;/p&gt;

&lt;p&gt;Notice the shape of that list. Testing, review, bisect, rollback, audit: essentially every practice we call "software engineering" is a child of determinism. It is not one nice property among many. It is the property the rest of the discipline is built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottleneck moved
&lt;/h2&gt;

&lt;p&gt;Here is the economic argument, because the craft argument never convinces anyone in a planning meeting.&lt;/p&gt;

&lt;p&gt;For most of my career, producing the artifact was the expensive part. Writing the code, the config, the migration, the docs. So that is where the effort, the tooling, and the prestige went.&lt;/p&gt;

&lt;p&gt;That cost just fell off a cliff. Code, tests, configs, entire services: generating a plausible version of almost anything is now nearly free. And when the cost of production approaches zero, the remaining cost of software is the cost of verification. Checking that the plausible thing is actually true is no longer a phase of the project. It is the project.&lt;/p&gt;

&lt;p&gt;The asymmetry is brutal. Generated output scales with compute, which gets cheaper every quarter. Verification scales with human attention, which does not scale at all. Every team I talk to is discovering the same imbalance: the pipeline that produces changes got a jet engine, and the pipeline that validates them still runs on the same few pairs of eyes it had in 2019.&lt;/p&gt;

&lt;p&gt;So the scarce skill inverted. The valuable engineer of 2020 was the one who could produce faster. The valuable engineer of 2026 is the one who can look at something plausible and determine, efficiently and reliably, whether it is correct. Plausible is the commodity now. Correct is still artisanal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the non-determinism where wrong is cheap
&lt;/h2&gt;

&lt;p&gt;None of this means banning models from production. It means placing them deliberately, the way you place any component with a known failure mode.&lt;/p&gt;

&lt;p&gt;A probabilistic component belongs where a wrong answer is cheap and checkable: a draft, a suggestion, a summary, a first pass that a cheaper process can validate. It does not belong holding your state, moving your money, or standing alone in front of an irreversible action.&lt;/p&gt;

&lt;p&gt;The pattern that works is deterministic rails around a probabilistic core. And the rails are built from the least glamorous toolbox in the profession: schemas validated at the boundary, state machines whose enums make illegal transitions unrepresentable, idempotency keys so a confused retry collapses into one effect instead of two, version-bound writes so decisions made on stale data get rejected instead of applied, a plan-then-commit gate in front of anything with blast radius, and an append-only log so "what did the system do?" never depends on anyone's memory.&lt;/p&gt;

&lt;p&gt;I wrote a whole article on the how of those rails at one specific boundary, &lt;a href="https://dev.toURL-MCP-WRITE-ARTICLE"&gt;agents writing to production systems&lt;/a&gt;: where each mechanism goes, what each one catches, what the server contract looks like. Consider this piece the why. Nothing in that toolbox was invented this decade, and that is exactly the point: it is decades of transactional common sense, redeployed at a new boundary, the line between a model and everything you care about. The model gets to be creative precisely because the rails are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read it back before you believe it
&lt;/h2&gt;

&lt;p&gt;I have a rule I apply to everything now, and it started long before agents: a system's report about itself is a claim, not a fact. Read it back before you believe it.&lt;/p&gt;

&lt;p&gt;The agent says it published the article? Fetch the article through the same path a reader would, and look at it. The migration says it ran? Count the rows. The dashboard is green? Green means "the things we chose to measure are within the thresholds we chose to set", which is a much smaller statement than "everything is fine". Your own code from last year? Same treatment. The author being you buys it nothing.&lt;/p&gt;

&lt;p&gt;For years this was a habit. Recently I understood it is actually a design requirement, and this is where it connects back to determinism: verifiability is a property you build in, not a virtue you exercise afterwards.&lt;/p&gt;

&lt;p&gt;A deterministic system is cheap to verify. One run is proof; the test you wrote once keeps testifying forever. A probabilistic system can be made verifiable too, but not for free: you have to design the read-back paths, the invariants, the reconciliation jobs, the alert that fires when the report and the reality disagree. Skip that work and you have not built a system, you have built a story the system tells you about itself.&lt;/p&gt;

&lt;p&gt;The question that changed how I build is not "does it work?" It is "how will I know it is still working when I am not watching?" A system that cannot answer that question is not finished, no matter what the demo looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boring is a career strategy
&lt;/h2&gt;

&lt;p&gt;Ten years ago, Dan McKinley told us to choose boring technology: spend your innovation tokens on your actual problem, not on your stack. The 2026 version of that advice is one level deeper: choose boring properties. Determinism. Idempotency. Reproducibility. Auditability. The stack will change under you; the properties transfer.&lt;/p&gt;

&lt;p&gt;Because here is the career math nobody puts on a slide. When everyone on the team can generate code, generating code stops being what distinguishes anyone. The floor rose; the ceiling did not move. What is scarce in a room full of people who can produce plausible artifacts is the person who can say "this is correct, and here is how I know", and have the second half of that sentence be load-bearing.&lt;/p&gt;

&lt;p&gt;That skill compounds, too. The prompt tricks of last spring are already quaint. The ability to design an invariant, bound a blast radius, or smell a non-reproducible test has been appreciating since the seventies and shows no sign of stopping.&lt;/p&gt;

&lt;p&gt;Nobody demos idempotency keys. Demos are generation; production is verification. The distance between the two is where reputations are quietly being made right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship the boring thing
&lt;/h2&gt;

&lt;p&gt;The systems I trust all have the same personality: they do the same thing every time, and they can prove it. When I add a model to one of them, and I do, I add it where being wrong is cheap, with rails around it that never improvise.&lt;/p&gt;

&lt;p&gt;Determinism is a feature. Some years I would have said an underrated one. This year I will go further: it is the feature, the one that makes all the others checkable.&lt;/p&gt;

&lt;p&gt;Ship it. And read it back before you believe it.&lt;/p&gt;

&lt;p&gt;What is the most boring thing in your stack you would defend to the death? Genuinely curious.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>ai</category>
      <category>career</category>
      <category>testing</category>
    </item>
    <item>
      <title>The Cows Don't Send Alerts</title>
      <dc:creator>Pierre- Laurent Medori</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:45:06 +0000</pubDate>
      <link>https://dev.to/pierrelaurentmedori/the-cows-dont-send-alerts-2n3f</link>
      <guid>https://dev.to/pierrelaurentmedori/the-cows-dont-send-alerts-2n3f</guid>
      <description>&lt;p&gt;My neighbor spent yesterday morning clearing a patch of maquis with a bulldozer. I watched him from the fence for a while, coffee in hand, contributing nothing. Then I walked down to check that the cows had water, because it's August in Corsica and that is the one job on this farm that cannot wait until later.&lt;/p&gt;

&lt;p&gt;The rest of the year I do engineering at GoodBarber, a mobile app platform. This month, my production system is a herd of cows and a water trough.&lt;/p&gt;

&lt;p&gt;This is not a burnout story. I'm fine, the team is fine, nothing is on fire. This is about something I re-learn every summer at my parents' farm and manage to forget again by October: disconnecting is not the opposite of engineering. It's where my engineering comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job I closed the laptop on
&lt;/h2&gt;

&lt;p&gt;For the past few months my work has looked like this: agents that draft, publish and verify things through MCP servers, pipelines that touch production, and one rule I repeat so often my team can probably recite it in their sleep: grep and eyes. When an agent says it did something, you go read the actual output, with your actual eyes, before you believe it. And a human stays in the loop for anything that ships.&lt;/p&gt;

&lt;p&gt;I like this work. A lot. Which is exactly why closing the laptop for two weeks felt harder than it should have. When you love the loop, you stop noticing you're inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The farm
&lt;/h2&gt;

&lt;p&gt;The heat arrives before you finish your coffee. By nine the cicadas are at full volume and they won't stop until dark; after a day you stop hearing them, a wall of sound your brain files under silence.&lt;/p&gt;

&lt;p&gt;The maquis smells like it's cooking. Immortelle, myrtle, hot dust. When the bulldozer bites into it the smell turns sharp, crushed green and diesel, and my father comments on the neighbor's technique from three fields away.&lt;/p&gt;

&lt;p&gt;The trough is a twenty-minute walk, morning and evening. You put your hand in the water, because looking is not enough: the surface can shine while the float valve sits jammed. The cows watch you do this with monumental indifference.The soft tick of the electric fence. On the way back you kick the same pine cone the whole length of the path, because there is nothing else to do. It takes a few days to understand that nothing else to do is the entire point.&lt;/p&gt;

&lt;p&gt;My phone lives in the kitchen. Half the property has no signal anyway. Evenings are long: tomatoes that actually taste of tomato, a card game nobody remembers the full rules of, chairs on the terrace facing the dark. When the cicadas finally stop, the silence has a texture. The silence in my apartment is just a machine that hasn't beeped yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distance gives you back your judgment
&lt;/h2&gt;

&lt;p&gt;From the hill behind the house you can see the whole farm at once: which field burned yellow, where the fence sags, where the water runs and where it doesn't. None of that is visible from inside the barn.&lt;/p&gt;

&lt;p&gt;For months my days were a hundred items deep. A flaky deploy. A graph doing something odd. An agent behaving strangely at 2 a.m. Every one of those items legitimate, none of them wrong to look at, and together they formed a wall between me and the only question that actually matters: what shape is the system, and where is it sagging?&lt;/p&gt;

&lt;p&gt;Two weeks of distance, and the shape comes back on its own. Without opening a laptop, I can tell you the two things that genuinely worry me for the autumn. In June I couldn't have, and in June I had all the dashboards.&lt;/p&gt;

&lt;p&gt;Judgment turns out to be a renewable resource with a single supplier: distance from the thing you're judging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard bugs die in boredom
&lt;/h2&gt;

&lt;p&gt;Every engineer carries one around: the bug that survives every fix. You fix it, it comes back wearing a different stack trace. Mine came on vacation with me, uninvited. They always do.&lt;/p&gt;

&lt;p&gt;The answer did not arrive at a desk. It arrived on day six, somewhere between the trough and the gate, wet hands, no phone. I didn't write a line of code. I just finally saw it: the whole mechanism at once, including the part every previous fix had been politely stepping around.&lt;/p&gt;

&lt;p&gt;Boredom without notifications is not empty time. It's the only state in which the complete model of a hard problem fits in your head, because nothing is evicting it every ninety seconds. A notification doesn't need to be answered to cost you. It only needs to arrive.&lt;/p&gt;

&lt;p&gt;We talk about deep work as if it were a scheduling technique. I'd call it something simpler: it's what your brain does by default when nothing is allowed to interrupt it. The farm doesn't teach you to focus. It just stops preventing you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Urgent is a word that means something here
&lt;/h2&gt;

&lt;p&gt;On a farm in August, urgent is unambiguous. Cows without water is urgent. The smell of smoke on the wind is urgent. Everything else waits, and waiting costs nothing.&lt;/p&gt;

&lt;p&gt;The cows don't send alerts, by the way. You find out how they're doing by showing up, twice a day, whether anything is wrong or not. There is probably a monitoring philosophy hiding in that sentence, but I promised myself I wouldn't write it on vacation.&lt;/p&gt;

&lt;p&gt;Before leaving I skimmed the threads marked urgent across my inboxes. Dozens. I already know what I'll find when I get back, because it's the same every year: most of them will have resolved themselves, gone stale, or turned out to be someone else's adrenaline. What remains is the real work, and it was there all along, hidden in plain sight under the pile.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable part: I can't tell the two apart while I'm in the pile. Nobody can. Up close, all urgency produces the same cortisol. Telling signal from noise takes distance, and you cannot acquire distance while you're busy responding. The 90% of noise was never a triage failure. It was the absence of the one tool that makes triage possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grep and eyes, with actual eyes
&lt;/h2&gt;

&lt;p&gt;Here is the thing I actually came to say.&lt;/p&gt;

&lt;p&gt;Checking the trough with your hand because the surface can lie to you: that is grep and eyes. Walking the fence line instead of assuming it held: that is verification before belief. The rule I repeat to my team all year was not invented at a whiteboard. It was installed here, a long time ago, by people who never wrote a line of code and would never trust a report about anything they could go and check themselves.&lt;/p&gt;

&lt;p&gt;The farm and the job run on the same values. Look with your own eyes. Keep a human in the loop. Know the difference between urgent and loud. I don't disconnect to get away from engineering. I disconnect to visit the place my engineering came from, and I come back with the judgment, the unstuck bug, and a short list of the things that actually matter.&lt;/p&gt;

&lt;p&gt;The neighbor finished his patch of maquis yesterday. Where there was scrub there is now clean ground, ready for whatever comes next. Two more weeks and it's my turn.&lt;/p&gt;

&lt;p&gt;The first thing I'll do back at work is not open a dashboard. I'll go look at the system with my own eyes.&lt;/p&gt;

</description>
      <category>career</category>
      <category>wellbeing</category>
      <category>devlife</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
