<?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: Craig Solomon</title>
    <description>The latest articles on DEV Community by Craig Solomon (@craig_solomon).</description>
    <link>https://dev.to/craig_solomon</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%2F3861408%2F39f02f6f-1ce1-419c-8a99-0b0d19a1fa28.png</url>
      <title>DEV Community: Craig Solomon</title>
      <link>https://dev.to/craig_solomon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/craig_solomon"/>
    <language>en</language>
    <item>
      <title>What crosses the network when you timestamp a file, and how to check it yourself</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/what-crosses-the-network-when-you-timestamp-a-file-and-how-to-check-it-yourself-1nbh</link>
      <guid>https://dev.to/craig_solomon/what-crosses-the-network-when-you-timestamp-a-file-and-how-to-check-it-yourself-1nbh</guid>
      <description>&lt;p&gt;Here's the question worth asking about your own provenance setup before any of the fun parts: at verification time, what has to be up?&lt;/p&gt;

&lt;p&gt;Not at build time. At verification time. Months from now, when someone asks you to prove a file existed on the date you say it did, which machines, which services, which credentials have to be alive and reachable for you to answer?&lt;/p&gt;

&lt;p&gt;It's an easy question to skip, because the issuing side is the part you actually test. You wire up the attestation step, it goes green in CI, you move on.&lt;/p&gt;

&lt;p&gt;So let's work it through properly. There are two phases and they have completely different dependency profiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase one: issuing has to touch the network, and that's fine
&lt;/h2&gt;

&lt;p&gt;If you want a digest anchored to a chain, something has to put it there. There's no clever way around that. You hash the file locally and then you hand that hash to a service that batches it into a Merkle tree and anchors the root.&lt;/p&gt;

&lt;p&gt;In my tool that's one subcommand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;verify-proof

verify-proof &lt;span class="nb"&gt;hash&lt;/span&gt; ... &lt;span class="c"&gt;# SHA-256 a file&lt;/span&gt;
verify-proof verify ... &lt;span class="c"&gt;# check a proof against the hash and its Merkle path&lt;/span&gt;
verify-proof create ... &lt;span class="c"&gt;# anchor a hash (added in 0.3.0)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three subcommands. Exactly one of them opens a socket: &lt;code&gt;create&lt;/code&gt;. It needs a free ProofLedger API key, and it sends the hash, never the file.&lt;/p&gt;

&lt;p&gt;Now, the obvious reading of that last sentence is "good, nothing leaks, we're done on the egress question."&lt;/p&gt;

&lt;p&gt;That reading is not enough, and this is the part of the post I actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A digest is a commitment, not a blindfold
&lt;/h2&gt;

&lt;p&gt;Sending a hash instead of a file is not the same as sending nothing. A SHA-256 digest is a commitment to the content. Anyone holding it can't read your file, but they can confirm a guess at it. Feed in a candidate, hash it, compare.&lt;/p&gt;

&lt;p&gt;Whether that matters depends entirely on how guessable the content is. A multi-megabyte binary with embedded build IDs, fine, nobody is guessing that. A one-line config value. A short contract built from a template where the only variable fields are a name and a number. A CSV row. Those are a different story, and the hash of one of them is closer to a lookup key than to a secret.&lt;/p&gt;

&lt;p&gt;So the protection in a hash-only transport is not "a hash is unreadable." It's "the file never moved off the machine." That's a real property and it's worth stating in those terms, because stated correctly you can see what it does and does not buy you, and you can decide for yourself whether the digest of this particular artifact is something you're comfortable handing to anyone.&lt;/p&gt;

&lt;p&gt;If it isn't, salt the thing before you hash it. Hash a file that includes a random value you keep alongside the proof. Then the digest is no longer a lookup key for a guessable document, and you've traded it for one more piece of state you have to not lose. That's the tradeoff, and it's yours to make rather than mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase two: verifying, and what "local" actually has to mean
&lt;/h2&gt;

&lt;p&gt;Here's where the dependency question gets sharp.&lt;/p&gt;

&lt;p&gt;Verification is hashing and more hashing. Recompute SHA-256 over the file, then fold your leaf up the Merkle path, concatenating and hashing at each level until you have a root. That's all arithmetic. No network required.&lt;/p&gt;

&lt;p&gt;Except the root you just computed is worthless on its own. You have to compare it against the root that's actually anchored on chain, and that value cannot come from the proof file, because the proof file is the thing you're checking. So it comes from a block explorer, from a node you run, or from a copy of the root you captured and trusted at the time.&lt;/p&gt;

&lt;p&gt;Which means "offline" was never the right property to ask for. The right property is narrower and more useful: &lt;strong&gt;verification must not depend on the issuer.&lt;/strong&gt; A chain lookup is a dependency you can satisfy many ways, including with hardware you control. A call to &lt;code&gt;api.vendor.com/verify&lt;/code&gt; is a dependency with exactly one supplier, and that supplier is the party whose claim you're trying to check.&lt;/p&gt;

&lt;p&gt;That's the whole reason I wrote the verify path to recompute and fold locally rather than ask an API whether the proof is good. It needs no key and no account. The asymmetry is deliberate: creating a proof costs you a credential and a network call, checking one costs you neither. That also means it can still check proofs issued by ProofAnchor, a service that's retired and isn't coming back. The anchors are on Polygon and Bitcoin, and both of those will answer a question about an old block whether or not the issuer still exists.&lt;/p&gt;

&lt;p&gt;Same split holds if you run it as an MCP server, which is optional (&lt;code&gt;pip install verify-proof[mcp]&lt;/code&gt;, built on FastMCP) and exposes five tools so Claude Desktop or Cursor can do this inside a conversation. The create tool still wants a key and still talks out. The verify side still doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, which you can run today against whatever you're using now
&lt;/h2&gt;

&lt;p&gt;Forget my tool for a second. Here's how to find out what your current setup depends on. Takes one sitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill the issuer, keep the chain.&lt;/strong&gt; Block egress to your vendor's API hostname at the firewall and leave the rest of your network up. Then verify a proof you already hold. If it passes, your verify path is talking to a chain and doing its own math. If it fails, you've learned that your ability to substantiate your own claim is a function of that company's uptime and your account's standing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull the credential.&lt;/strong&gt; Unset every API key and token in the environment and verify again. A verifier that needs a credential is a verifier that can be revoked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the issuing call.&lt;/strong&gt; Compute the digest yourself first with &lt;code&gt;sha256sum&lt;/code&gt;, then capture the traffic while the attestation step runs and search the capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum &lt;/span&gt;contract.pdf &lt;span class="c"&gt;# note the hex digest&lt;/span&gt;

&lt;span class="c"&gt;# run your create/attest step with a capture or proxy in front of it, then:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;that digest&amp;gt;'&lt;/span&gt; capture.txt &lt;span class="c"&gt;# expect a hit&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;a string that exists only in the file&amp;gt;'&lt;/span&gt; capture.txt &lt;span class="c"&gt;# expect zero&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second grep returns anything above zero, your file body is going over the wire and you should know that before the next audit, not during it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what the root is compared against.&lt;/strong&gt; Read your proof file. If the anchored root is sitting in it and your verifier compares the computed root to that field, the check is internally consistent and externally meaningless.&lt;/p&gt;

&lt;p&gt;One honest limit on all of this: none of it tells you a file is genuine, or that a particular person wrote it. It tells you a specific sequence of bytes existed at a specific time. That's narrow, and it's often exactly the thing in dispute, but don't let it grow in the retelling.&lt;/p&gt;

&lt;p&gt;verify-proof is MIT licensed and free, on PyPI and at &lt;a href="https://github.com/Fulcrum-Enterprises/verify-proof" rel="noopener noreferrer"&gt;https://github.com/Fulcrum-Enterprises/verify-proof&lt;/a&gt;. I built it and I run it.&lt;/p&gt;

&lt;p&gt;The part I'd genuinely like other people's answers on: for long-lived artifacts, do you store the anchored root yourself at issue time, or do you plan on querying the chain whenever the question comes up? I went with the second and I keep wondering if the first is the adult decision.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this article was drafted by an AI agent I built and run, from facts I supplied about my own project.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>devops</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Testing the deny path: how to prove an MCP server's guards hold</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/testing-the-deny-path-how-to-prove-an-mcp-servers-guards-hold-119l</link>
      <guid>https://dev.to/craig_solomon/testing-the-deny-path-how-to-prove-an-mcp-servers-guards-hold-119l</guid>
      <description>&lt;p&gt;An MCP server's guards are the only code in the project where the test that matters asserts that nothing happened. No row came back. No request left the box. No file was opened. That is an awkward thing to assert, and it is the reason a guard suite can be green while proving almost nothing about the guard.&lt;/p&gt;

&lt;p&gt;Here is the shape I use, and the two mistakes it is designed to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the guard a decision you can call
&lt;/h2&gt;

&lt;p&gt;If the check lives inside the tool body, the only way to test it is to drive the whole tool and infer the decision from whatever came back. Pull the decision out so it is a function with a return value or an exception, and nothing else.&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="c1"&gt;# tools/files.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_in_sandbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_relative_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outside sandbox: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_text_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolve_in_sandbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&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;The same split works for the other two reaches an MCP server tends to hand a model. A SQL guard is &lt;code&gt;assert_read_only(sql)&lt;/code&gt; raising before &lt;code&gt;execute&lt;/code&gt;. An SSRF guard is &lt;code&gt;assert_host_allowed(url)&lt;/code&gt; raising before the HTTP client is ever handed the URL. In every case the guard is a pure decision over an input string, and the effect is a separate line that runs afterwards.&lt;/p&gt;

&lt;p&gt;Now the deny cases are a table:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tools.files&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_text_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolve_in_sandbox&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;root&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested&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;../../etc/passwd&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;/etc/passwd&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;notes.txt/../../../etc/passwd&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;./../notes.txt&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_escape_is_denied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="nf"&gt;resolve_in_sandbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parametrize rather than loop inside one test. When a new bypass turns up you add a string, and when one regresses pytest names the exact input in the failure line instead of telling you that &lt;code&gt;test_paths&lt;/code&gt; failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deny-only suite that passes for the wrong reason
&lt;/h2&gt;

&lt;p&gt;Here is mistake one. A guard test file made entirely of &lt;code&gt;pytest.raises&lt;/code&gt; blocks passes if you replace the body of the guard with &lt;code&gt;raise Denied("nope")&lt;/code&gt;. Every attack string is rejected. So is every legitimate request. The suite is green and the tool is dead.&lt;/p&gt;

&lt;p&gt;So pin the allow path in the same file, right next to the denials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_plain_name_is_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;resolve_in_sandbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can check this property deliberately, and it costs one edit. Make the guard unconditionally deny, run the suite, and look at what fails. If only your allow tests go red, the deny tests are real. If nothing goes red, your deny tests were never testing the guard. Then make the guard unconditionally allow and confirm the whole deny table lights up. Undo both. That pair of edits tells you more about a guard suite than reading it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asserting that nothing happened
&lt;/h2&gt;

&lt;p&gt;Mistake two is subtler. &lt;code&gt;pytest.raises(Denied)&lt;/code&gt; proves an exception came out. It does not prove the effect never ran. A guard that checks the path after opening the file raises exactly the same exception and leaks exactly the same bytes into your process first. Order is the whole property, and the exception type cannot see order.&lt;/p&gt;

&lt;p&gt;You can test order directly by booby-trapping the effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_denied_read_never_touches_disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_text ran on a denied path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="nf"&gt;read_text_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;../../etc/passwd&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;If the guard runs first, &lt;code&gt;read_text&lt;/code&gt; is never reached and the test passes. If someone reorders the function later, the &lt;code&gt;AssertionError&lt;/code&gt; surfaces instead of &lt;code&gt;Denied&lt;/code&gt; and the test fails with a sentence describing what went wrong. The same trick applies anywhere an effect is reachable through a seam: swap the DB cursor for an object whose &lt;code&gt;execute&lt;/code&gt; raises, swap the HTTP client for one whose &lt;code&gt;send&lt;/code&gt; raises, and your deny tests start making a claim about sequencing rather than about exception types.&lt;/p&gt;

&lt;p&gt;This is also the cheapest way to test an SSRF guard without network access in CI. You are not asserting that the request failed. You are asserting that no request was ever constructed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the wiring, not just the guard
&lt;/h2&gt;

&lt;p&gt;A guard can be correct, fully covered, and protect nothing, because the handler the MCP protocol actually dispatches to is a different callable than the one your unit tests import. An unguarded helper left registered, a decorator applied to the wrong function, a second code path added for a batch variant. Unit tests on &lt;code&gt;resolve_in_sandbox&lt;/code&gt; cannot see any of that.&lt;/p&gt;

&lt;p&gt;So add tests one layer up that go in by tool name, the way a client does, and assert the request is refused:&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="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool,args&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_text_file&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;path&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;../../etc/passwd&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;query_database&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;sql&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;DELETE FROM users&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;call_api&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;url&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;http://169.254.169.254/latest/meta-data/&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_dispatch_refuses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guard-level tests tell you the decision is right. Dispatch-level tests tell you the decision is in the path. You want both, and they fail for different reasons, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not get you
&lt;/h2&gt;

&lt;p&gt;Enumerated strings only cover the bypasses you thought of. A table of traversal inputs is a regression net, not a proof, and the interesting failures usually live one layer below your guard: how the OS normalizes a path before you ever see it, how symlinks and case-insensitive filesystems change what &lt;code&gt;resolve()&lt;/code&gt; means, how a given SQL dialect treats a construct you did not plan for. If you need a stronger promise than string-level checking, you move the enforcement down to where the effect happens: a connection opened read-only, a filesystem handle confined by the kernel, an outbound proxy that only knows about hosts you listed.&lt;/p&gt;

&lt;p&gt;Name-level checking has its own ceiling. Deciding that a hostname is allowed is a decision about a name, and the address behind a name is resolved separately. If you need an address-level guarantee, pin the resolved address and connect to that, or put the allowlist in a proxy outside the process.&lt;/p&gt;

&lt;p&gt;And none of this is about resource use. A read-only guard says a statement cannot write. It says nothing about a statement that scans your largest table, so timeouts, row caps and statement limits are a separate job.&lt;/p&gt;

&lt;p&gt;If your MCP server only exposes computation over data you already shipped inside it, skip most of this. The guards earn their keep the moment a tool takes a string from the model and turns it into a URL, a query, or a path.&lt;/p&gt;

&lt;p&gt;I wrote these guards once and kept them. The MCP Starter Kit is the production-shaped Python MCP server I built, maintain and run, with the SSRF host allowlist, the read-only SQL guard, the path-traversal sandbox and a 14-test pytest suite over the guards and tool dispatch: &lt;a href="https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto&amp;amp;v=57dd17" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto&amp;amp;v=57dd17&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>pytest</category>
      <category>security</category>
    </item>
    <item>
      <title>What a timestamp proof actually proves, and the bytes you have to keep</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Sun, 04 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/what-a-timestamp-proof-actually-proves-and-the-bytes-you-have-to-keep-16f</link>
      <guid>https://dev.to/craig_solomon/what-a-timestamp-proof-actually-proves-and-the-bytes-you-have-to-keep-16f</guid>
      <description>&lt;p&gt;Ask this about your own setup: if a timestamp proof verifies clean, what are you entitled to say out loud?&lt;/p&gt;

&lt;p&gt;Here's the honest version. This exact digest existed no later than the block it was anchored in.&lt;/p&gt;

&lt;p&gt;That's the whole claim. Not that the file is genuine. Not that you wrote it. Not that nobody has touched it since. A hash existed at a time, and nothing past that. Everything useful you can build on a timestamp proof rests on that sentence.&lt;/p&gt;

&lt;p&gt;Start with the easy half, because you can run it right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;verify-proof
verify-proof &lt;span class="nb"&gt;hash &lt;/span&gt;contract.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a SHA-256 digest of the file's bytes. The &lt;code&gt;verify&lt;/code&gt; subcommand takes a proof file and the hash it's supposed to be for, recomputes that hash, and walks the Merkle path to the root itself instead of asking an API whether things look fine. Verification is local, no key and no account. The only subcommand that touches the network is &lt;code&gt;create&lt;/code&gt;, which anchors a hash through ProofLedger and sends the hash, never the file.&lt;/p&gt;

&lt;p&gt;So far so boring. Here's where it gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim is attached to a digest, and the digest is attached to exact bytes
&lt;/h2&gt;

&lt;p&gt;Once you accept that the proof is a statement about a digest, the next question is what you have to preserve for that statement to stay worth anything. The obvious answer is the proof file. Archive the proof, keep it somewhere durable, done.&lt;/p&gt;

&lt;p&gt;That answer is not enough, and the gap is the point of this post.&lt;/p&gt;

&lt;p&gt;A proof file is a pointer to a digest. The digest is a pointer to a specific sequence of bytes. If you still hold the proof but can no longer produce bytes that hash to that digest, you hold a verifiable statement about something you can't show anyone. The math still checks out. It just checks out about an artifact you lost.&lt;/p&gt;

&lt;p&gt;And you can lose it without ever losing the file, which is the part worth internalizing. The digest is over bytes, not over content. Not over "the document", not over the text a human reads on screen. Change the container and leave the meaning identical, and the digest is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that rewrite bytes while leaving content alone
&lt;/h2&gt;

&lt;p&gt;I'm not going to tell you what's common in other people's pipelines, because I haven't read them. I'll tell you what to go look for in yours. Any step that deserializes and re-serializes is a candidate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A PDF opened and re-saved by a different tool, which may rewrite the object table, the producer string, or embedded dates.&lt;/li&gt;
&lt;li&gt;JSON parsed and dumped again, with different key order, different whitespace, or different unicode escaping.&lt;/li&gt;
&lt;li&gt;Line ending conversion on checkout, or an editor normalizing the trailing newline.&lt;/li&gt;
&lt;li&gt;Images re-encoded on upload, stripped of metadata, or thumbnailed in place.&lt;/li&gt;
&lt;li&gt;A zip or tarball rebuilt, with different member order, different mtimes, different compression levels.&lt;/li&gt;
&lt;li&gt;Office formats, which are zips, so everything above applies twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are attacks. They're ordinary helpful behavior from ordinary tools. The proof doesn't know the difference between a helpful re-save and a malicious edit, and it shouldn't. That's not weakness, it's the thing you wanted. A digest that forgave cosmetic changes would also forgive the change somebody made on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bites in practice
&lt;/h2&gt;

&lt;p&gt;The failure doesn't show up when you create the proof. It shows up later, when something matters and you go to produce the artifact. By then the file has moved through a storage bucket, maybe a document viewer, maybe a mail client, maybe an export and a re-import. You still have a file called &lt;code&gt;contract.pdf&lt;/code&gt;. It still looks right. It hashes to something else.&lt;/p&gt;

&lt;p&gt;So the operational rule falls out of the mechanism rather than out of anybody's policy doc. Anchor the bytes you can commit to storing untouched, and store those bytes, not a convenient copy of them. If your workflow has to transform a file, decide which form is the one of record, hash that one, and let the transformed copies be copies. If you can't keep the raw bytes, anchoring a canonical serialization you can regenerate deterministically is a better bet than anchoring whatever came out of the tool that day.&lt;/p&gt;

&lt;p&gt;The same thinking applies to anything you want to anchor from an agent session. There's an optional MCP server, &lt;code&gt;pip install verify-proof[mcp]&lt;/code&gt;, built on FastMCP, exposing five tools so Claude Desktop or Cursor can verify a proof or create one inside a conversation. Convenient, but it doesn't change the rule. Whatever bytes you handed the hash function are the bytes you're on the hook for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, run it today
&lt;/h2&gt;

&lt;p&gt;Pick a file you have a proof for, or any file you'd want one for. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;verify-proof hash&lt;/code&gt; it and write the digest down.&lt;/li&gt;
&lt;li&gt;Push it through your normal path. Upload it and download it. Commit and check out on another machine. Attach it to a ticket and pull it back. Export and re-import. Whatever your real handling actually is.&lt;/li&gt;
&lt;li&gt;Hash the result and compare.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If they match, you know your pipeline is byte-preserving and your proofs will survive it. If they don't, you just found out your archive copy and your anchored digest are about different things, which is much better to learn now than when you need the proof.&lt;/p&gt;

&lt;p&gt;Second test, while you're in there. Take a proof you were issued by somebody and see whether you can check it with the issuer's service unreachable. If verifying requires their endpoint to answer, the proof is a claim with a dependency on the claimant staying online and willing. Proofs anchored to Polygon and to Bitcoin can be verified from the proof file and the chain, which includes legacy proofs issued by the retired ProofAnchor service, and that's exactly the case worth checking, because the issuer isn't there to ask anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I don't have a clean answer for
&lt;/h2&gt;

&lt;p&gt;Formats you can't freeze. A record that lives in a database row, a doc that's edited collaboratively, anything where "the file" is a rendering rather than a stored artifact. You can anchor a canonical export, but then your proof is about your canonicalizer, and the canonicalizer becomes a thing you have to version and preserve alongside the proof. I've picked the raw bytes side of that tradeoff, and the cost is real: it pushes the discipline onto whoever stores the file.&lt;/p&gt;

&lt;p&gt;If you've dealt with that for live records, I'd like to hear which way you went and what broke.&lt;/p&gt;

&lt;p&gt;The tool is &lt;code&gt;verify-proof&lt;/code&gt;, free and MIT licensed, published by Fulcrum Enterprises LLC. I built it, I maintain it, and I run it myself. Source is at &lt;a href="https://github.com/Fulcrum-Enterprises/verify-proof" rel="noopener noreferrer"&gt;https://github.com/Fulcrum-Enterprises/verify-proof&lt;/a&gt; and the package is on PyPI.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>blockchain</category>
      <category>mcp</category>
    </item>
    <item>
      <title>How an auto-publishing agent earns its permissions, channel by channel</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/how-an-auto-publishing-agent-earns-its-permissions-channel-by-channel-39dm</link>
      <guid>https://dev.to/craig_solomon/how-an-auto-publishing-agent-earns-its-permissions-channel-by-channel-39dm</guid>
      <description>&lt;p&gt;A publish permission wants to be a boolean. &lt;code&gt;AUTO_PUBLISH=true&lt;/code&gt;, ship it, go do something else.&lt;/p&gt;

&lt;p&gt;The trouble with the boolean is what it encodes: that the agent is trustworthy in general. Trust is not general. A drafting agent can be perfectly sound writing a technical post for a dev feed and be a liability in a subreddit where a link in the body reads as spam. Same model, same prompt, different blast radius.&lt;/p&gt;

&lt;p&gt;So in Content Agent Pro the auto-publish check is not a flag I set. It is a conjunction evaluated per &lt;code&gt;(product, channel)&lt;/code&gt; pair, and most of the terms can go false without me touching the agent at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;can_autopublish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;master_switch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&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;master auto-publish switch is off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;free_api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&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;channel has no free publishing API&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;credentials_present&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&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;credentials missing for this channel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

 &lt;span class="n"&gt;reviewed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rejected&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reviewed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_REVIEWED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# 10
&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;only &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reviewed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reviewed drafts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;reviewed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_RATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# 0.90
&lt;/span&gt; &lt;span class="k"&gt;return&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;approval rate below threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;graduated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MIN_REVIEWED = 10&lt;/code&gt; and &lt;code&gt;MIN_RATE = 0.90&lt;/code&gt; are the graduation thresholds. Everything interesting about this function is in the details around those two numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence is reviewed drafts, not drafts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;reviewed&lt;/code&gt; is &lt;code&gt;approved + rejected&lt;/code&gt;. Pending drafts are not in the denominator, and they should not be. A draft nobody looked at is not evidence about anything. If I let total drafts drive the gate, a quiet week where the scheduler runs and I never open the dashboard would push a pair toward autonomy on the strength of work no human ever read. That is the exact inversion of what the gate is for.&lt;/p&gt;

&lt;p&gt;This is worth checking in any track-record gate you build. The metric has to be &lt;em&gt;judged outcomes&lt;/em&gt;, and the judgment has to have actually happened. Anything that accrues on its own is a clock, not a record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit is the pair, not the agent
&lt;/h2&gt;

&lt;p&gt;State lives per &lt;code&gt;(product, channel)&lt;/code&gt;. Product A on Dev.to can be graduated while product A on Reddit is still fully manual and product B on Dev.to has never been reviewed.&lt;/p&gt;

&lt;p&gt;This falls out of how the drafting works. Each channel definition carries its own voice rules and link discipline, and link discipline in particular varies hard: footer link, profile-only, comment-only, no link at all. Nine channel definitions ship with the kit, and a draft that is honest and well-formed for one of them can be wrong for the next. The failure modes are not shared, so the trust record should not be either.&lt;/p&gt;

&lt;p&gt;Practically, that means your schema key is a composite, and your approval counters are columns on that pair row rather than globals. If you find yourself writing &lt;code&gt;SELECT avg(approved) FROM drafts&lt;/code&gt;, you have built a reputation score for the model. What you want is a reputation score for a specific job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two terms that are not about performance at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;master_switch&lt;/code&gt; and &lt;code&gt;credentials_present&lt;/code&gt; are not evidence, and they are in the conjunction on purpose.&lt;/p&gt;

&lt;p&gt;The master switch is the thing I can flip to make every graduated pair manual again, in one place, without editing per-pair state. If the only way to stop an agent is to walk through its permission records and revoke them one by one, you do not have a stop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;free_api&lt;/code&gt; is a constraint I encoded because of what the failure looks like when it is absent. Auto-publishing is only allowed on channels whose publishing API is free. Cost changes the shape of a runaway: a loop that posts too often on a free endpoint is an embarrassment you delete, and the same loop against a metered endpoint is a bill. The kit ships publishers for Dev.to and Bluesky, plus a Hashnode publisher that ships disabled, because Hashnode ended free API access and it now works only on a Hashnode Pro plan. That disabled publisher is the constraint doing its job in public rather than a feature I forgot to finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rate only climbs if rejection carries information
&lt;/h2&gt;

&lt;p&gt;A 90% approval threshold is only reachable if rejections change the next draft. So rejection in the dashboard is reject-with-reason, and the reason goes into a bounded window that gets injected into the next prompt for that pair: the last 8 reject reasons and the last 6 titles for the product and channel.&lt;/p&gt;

&lt;p&gt;Both windows are bounded, for different reasons. Reject reasons go stale as the voice settles, and an unbounded list of them turns the prompt into an archive of complaints the model has already absorbed. Recent titles are there so the next draft does not restate the last one under a new headline.&lt;/p&gt;

&lt;p&gt;If you are building this part yourself, the thing to get right is that the reason field is &lt;em&gt;free text written at the moment of rejection&lt;/em&gt;. A dropdown of categories is easier to aggregate and far weaker as an input, because the useful part is the sentence, not the label.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gate is not a substitute for a check
&lt;/h2&gt;

&lt;p&gt;Here is the part that took a design decision rather than a threshold. A track record is a statement about the past. Approval rate cannot catch a claim the model invents today.&lt;/p&gt;

&lt;p&gt;So the gate sits on top of deterministic validators that run on every draft, graduated or not. They are code, not prompt instructions. Any dollar amount, percentage, or large number that does not appear in the product's fact sheet hard-fails the draft. Hype words fail. AI-tell phrases fail. Unfilled placeholders fail. Per-product earnings language fails. Em dashes are handled differently: a sanitizer strips them, and the draft is not retried for it, because a character-level fix does not need a model round trip.&lt;/p&gt;

&lt;p&gt;The division of labor is: validators define what is never allowed to publish, and the gate defines when a human stops having to look. Those are different questions, and a system that only has one of them either publishes fabrications with a clean track record or asks you to catch fabrications by eye forever.&lt;/p&gt;

&lt;p&gt;In the dashboard, every draft card shows a validator flag count and the full findings, and &lt;code&gt;/gates&lt;/code&gt; renders the graduation matrix so the state of each pair is visible rather than inferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this does not help
&lt;/h2&gt;

&lt;p&gt;The honest limits, because they are the reason to decide deliberately rather than copy the pattern.&lt;/p&gt;

&lt;p&gt;The threshold is not a guarantee. Ninety percent is not a hundred, and a graduated pair will eventually publish something you would have edited. The validators are what keeps that from being a fabricated number under your name. They do not keep it from being a mediocre post.&lt;/p&gt;

&lt;p&gt;The record is historical and the model is not frozen. Change your product's fact sheet or your channel voice rules and the accumulated approval rate describes a job that no longer exists. Resetting a pair's counters after a material change to its inputs is a reasonable thing to add, and it is the kind of thing you have to decide for yourself, because only you know which config changes count as material.&lt;/p&gt;

&lt;p&gt;Gates also assume you actually review. They convert human attention into autonomy. If the drafts pile up unread, nothing graduates, and correctly so.&lt;/p&gt;

&lt;p&gt;And if you publish to one channel and you write the posts yourself, none of this is worth the schema. The mechanism earns its keep when there are several products, several channels with genuinely different rules, and a real cost to being wrong in public.&lt;/p&gt;

&lt;p&gt;Content Agent Pro is the self-hosted, MIT-licensed version of this: a Python agent and a Next.js dashboard over one SQLite file, which I built, maintain, and run myself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fulcrumenterprises.tech/go/content-agent-kit-pro/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/content-agent-kit-pro/?c=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to fold a Merkle path yourself, and why the root can't come from the proof file</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/how-to-fold-a-merkle-path-yourself-and-why-the-root-cant-come-from-the-proof-file-425b</link>
      <guid>https://dev.to/craig_solomon/how-to-fold-a-merkle-path-yourself-and-why-the-root-cant-come-from-the-proof-file-425b</guid>
      <description>&lt;p&gt;There's one question worth asking about any timestamp proof file you're holding right now: when you verify it, what exactly are you comparing your computed root against, and where did that value come from?&lt;/p&gt;

&lt;p&gt;Real question, real answer, and it's coming. But it doesn't mean much until you've folded a Merkle path by hand once, so let's do that part first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a proof file is asking you to do
&lt;/h2&gt;

&lt;p&gt;The setup is simple. You want evidence that a file existed by a certain time. Hashing it gives you a digest. Writing that digest to a public chain gives you a timestamp. Writing one transaction per file gets expensive, so anchoring services batch: they take a pile of digests, build a Merkle tree over them, and publish the root.&lt;/p&gt;

&lt;p&gt;Your digest is a leaf. The root is what actually got committed. The proof file is the receipt that connects the two, and it usually carries the leaf, the sibling hashes on the way up, the root, and something about where that root landed.&lt;/p&gt;

&lt;p&gt;Verifying means doing the walk yourself. Hash your file, fold it upward with the siblings, and see whether you arrive at the root.&lt;/p&gt;

&lt;p&gt;The leaf:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;leaf_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leaf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;leaf&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;sibling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromhex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;side&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sibling&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sibling&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the mechanism. Your format will name those fields something else, and it may encode the side as an index instead of a word, but the shape is that: a loop, a concatenation, a digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bites
&lt;/h2&gt;

&lt;p&gt;Look at the branch in that loop. Order of concatenation is the entire content of the algorithm. &lt;code&gt;sibling + node&lt;/code&gt; and &lt;code&gt;node + sibling&lt;/code&gt; produce completely unrelated digests, and once you've gone the wrong way at one level, every level above it is garbage. The fold doesn't drift off by a little. It lands somewhere else entirely.&lt;/p&gt;

&lt;p&gt;Which means there are a few things to pin down in whatever format you're handed, and they are not documented as loudly as you'd like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the format tell you the side, or imply it?&lt;/strong&gt; Reading an explicit side field and inferring side from index parity are different algorithms. So is sorting the pair before hashing, which some constructions do specifically to avoid carrying side information at all. Only one of those matches the tree that was actually built. Open your own proof file and find out which one it's describing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw bytes or hex text?&lt;/strong&gt; A digest has a binary form and a printable form, and hashing the printable form is a different operation from hashing the bytes it prints. If the tree was built by concatenating raw digests and your code concatenates hex strings, nothing will line up. The failure mode is nasty because it reads as "this proof is invalid" rather than "your decoder is wrong."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Once or twice?&lt;/strong&gt; Some Merkle constructions hash each pair a single time, some apply SHA-256 twice at every internal node. Check which one your format means before you conclude anything about the file.&lt;/p&gt;

&lt;p&gt;Every one of those is a place where a correct proof looks broken, or a broken proof looks fine because you and the builder disagree about what the bytes mean. This is why I'd rather have code that does the fold in front of me than a green check mark from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the obvious answer runs out
&lt;/h2&gt;

&lt;p&gt;So you write the loop, you fold the path, you land exactly on the root in the file. Feels finished.&lt;/p&gt;

&lt;p&gt;It isn't. Look at where each input came from.&lt;/p&gt;

&lt;p&gt;The leaf came from your file, and that part is load bearing. It binds the proof to the actual bytes on your disk, so if one byte changes, the leaf changes and the fold stops reaching the root. Good.&lt;/p&gt;

&lt;p&gt;But the path and the root both came out of the same document. So the thing you just proved is that the proof file is internally consistent with your file. That's a weaker statement than it feels like, because internal consistency is cheap to manufacture. Given any file at all, you can hash it, build a fresh tree that includes it, and emit a perfectly self-consistent proof today. The fold will check out. It says nothing about time, because nothing in it has been published anywhere.&lt;/p&gt;

&lt;p&gt;The root is the only value in that file that was ever committed outside the file. That's the part that carries the timestamp. Everything else is derivation.&lt;/p&gt;

&lt;p&gt;So the comparison that matters is not leaf-folded-against-root. It's root-against-chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for in your own proof file
&lt;/h2&gt;

&lt;p&gt;Open one. Find the root. Then ask whether the file gives you enough to resolve that root somewhere you don't control: a chain, a transaction identifier, a block. Something you could hand to a block explorer or your own node without asking the issuer's permission.&lt;/p&gt;

&lt;p&gt;If the root only ever appears inside the file, and the only way to confirm it was ever anchored is to call the issuer's API and get back a boolean, then the trust boundary hasn't moved. You're back to taking their word for it, with extra hashing in the middle. Correct folding code doesn't save you from that, which is the uncomfortable bit. The crypto can be perfect and the chain of trust can still be a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where verify-proof sits, and what it won't do
&lt;/h2&gt;

&lt;p&gt;verify-proof is a Python CLI, &lt;code&gt;pip install verify-proof&lt;/code&gt;. Three subcommands: &lt;code&gt;hash&lt;/code&gt; gives you the SHA-256 of a file, &lt;code&gt;verify&lt;/code&gt; checks a proof file against that hash and its Merkle path, and &lt;code&gt;create&lt;/code&gt; anchors a hash through ProofLedger, added in 0.3.0. It handles proofs anchored to Polygon and to Bitcoin, including legacy proofs from the retired ProofAnchor service. There's an optional MCP server, &lt;code&gt;pip install verify-proof[mcp]&lt;/code&gt;, built on FastMCP, exposing five tools, so you can verify or create inside a conversation in Claude Desktop or Cursor.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;verify&lt;/code&gt; recomputes the hash and walks the path itself rather than asking an API whether the proof is good. Only &lt;code&gt;create&lt;/code&gt; touches the network, and it sends the hash, never the file.&lt;/p&gt;

&lt;p&gt;Now the limit, because it follows directly from that design decision. Since verification does no network calls, the tool cannot go look at the chain for you. It will do the fold and it will do it locally, on a machine with no route out, with no API key and no account. It will not tell you the root was ever anchored. That step stays yours.&lt;/p&gt;

&lt;p&gt;I made that trade on purpose and I think it's the right one, but it is a trade, and I'd rather say so than describe an offline verifier as though it settles the on-chain question too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test you can run today
&lt;/h2&gt;

&lt;p&gt;Take a proof file you already have, from whoever issued it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cover up the root. Pretend it isn't there.&lt;/li&gt;
&lt;li&gt;Hash your file, then fold it up the path with the sibling hashes, using the loop above. Adjust for whatever your format says about side, encoding, and single or double hashing.&lt;/li&gt;
&lt;li&gt;Uncover the root and compare.&lt;/li&gt;
&lt;li&gt;Then take that computed root and go looking for it on the chain the file names, in an explorer or a node that is not the issuer's.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read the result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The fold doesn't reach the stated root. Either your reading of the format is off, or the file is. Fix the format questions first.&lt;/li&gt;
&lt;li&gt;The fold reaches the root, but you can't find the root on chain. You have a self-consistent document. You don't have a timestamp.&lt;/li&gt;
&lt;li&gt;The fold reaches the root, and the root is sitting in a block. Now you have something, and be precise about what: that digest existed by that block's time. Not that the file is genuine, not that anyone in particular wrote it. Just that those bytes existed by then.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if step 4 turns into "call our endpoint and we'll confirm it," you've answered the question at the top of this post. The answer just isn't the one you wanted.&lt;/p&gt;

&lt;p&gt;verify-proof is free and MIT licensed, published by Fulcrum Enterprises LLC. I built it and I maintain it. Source is at &lt;a href="https://github.com/Fulcrum-Enterprises/verify-proof" rel="noopener noreferrer"&gt;https://github.com/Fulcrum-Enterprises/verify-proof&lt;/a&gt; and the package is at pypi.org/project/verify-proof/, if you want something that does the fold in front of you.&lt;/p&gt;

</description>
      <category>python</category>
      <category>cryptography</category>
      <category>security</category>
      <category>blockchain</category>
    </item>
    <item>
      <title>Idempotent drafts: keeping a scheduled agent from writing the same post again</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/idempotent-drafts-keeping-a-scheduled-agent-from-writing-the-same-post-again-g3j</link>
      <guid>https://dev.to/craig_solomon/idempotent-drafts-keeping-a-scheduled-agent-from-writing-the-same-post-again-g3j</guid>
      <description>&lt;p&gt;A scheduler is an at-least-once system wearing an exactly-once costume. You register a job, it fires on a clock, and for a while the mental model holds: the job runs when you said it would, the drafter calls a model, a row lands in the database, you go look at it.&lt;/p&gt;

&lt;p&gt;Then reality shows up. The container restarts mid-run. You redeploy during a scheduled window. The host sleeps and the scheduler catches up on missed runs when it wakes. A model call times out at the transport layer, your retry wrapper fires, and the first call had already succeeded on the server side. The job body runs again, and because the job body is "draft something and insert it," you get another row.&lt;/p&gt;

&lt;p&gt;Duplicate drafts are not a cosmetic problem. A drafting agent exists to feed a human review step, and review is the scarce resource. Every duplicate row is a decision a person has to make and then unmake. Worse, the duplicates are not identical: same schedule, same prompt, different sampling, so the reviewer cannot even skim and dismiss. They have to read both.&lt;/p&gt;

&lt;p&gt;The fix is not a better scheduler. It is making the job body idempotent, so running it again is a no-op instead of a second draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check-then-write trap
&lt;/h2&gt;

&lt;p&gt;The obvious first move is to look before you leap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id FROM drafts WHERE platform = ? AND slot = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt;
 &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drafter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO drafts (platform, slot, body, status) VALUES (?, ?, ?, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ready&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a time-of-check-to-time-of-use race with a very wide window in the middle, because &lt;code&gt;drafter.write&lt;/code&gt; is a network call to a model. Anything that starts a second execution while the first one is waiting on that call passes the &lt;code&gt;SELECT&lt;/code&gt;, because the insert has not happened yet. The check is honest and still wrong.&lt;/p&gt;

&lt;p&gt;The general fix for this shape of bug is to stop asking the database a question and start giving it a constraint it can enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Derive a key, let the database refuse
&lt;/h2&gt;

&lt;p&gt;Give every draft a key that is a pure function of the work it represents, not of when the work happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;draft_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three parts, and what goes in each one matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;platform&lt;/code&gt; is the destination. The same idea drafted for different destinations is different work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;slot&lt;/code&gt; is the scheduled occurrence, derived from the run's scheduled time, not from the wall clock at execution. &lt;code&gt;scheduled_for.strftime("%Y-%m-%dT%H")&lt;/code&gt; gives you a stable string that a catch-up run and the original run both produce. If you use &lt;code&gt;now()&lt;/code&gt; here, a catch-up run gets its own key and you are back where you started.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recipe&lt;/code&gt; is a version marker for the drafter and prompt. This is the escape hatch: when you intentionally want a fresh draft for a slot you already drafted, you bump the recipe, and the new key is legitimately new.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the key readable if you can. A key you can eyeball in a SQL shell is worth a lot during debugging. If the parts get long, hash them, but then log the plain parts next to the hash.&lt;/p&gt;

&lt;p&gt;Then put the constraint where it cannot be raced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;drafts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;drafts_key&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;drafts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a dashboard reads the same database through Prisma, express the constraint there too so migrations carry it rather than living in a stray &lt;code&gt;.sql&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model Draft {
 id String @id @default(cuid())
 key String @unique
 platform String
 slot String
 status String
 body String?
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Claim the slot before you spend the model call
&lt;/h2&gt;

&lt;p&gt;A unique index alone turns the duplicate into a failed insert, which is correct but wasteful: you already paid for the model call before the database told you the work was redundant. Invert the order. Claim the key first, cheaply, then fill in the body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO drafts (key, platform, slot, status) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALUES (?, ?, ?, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claimed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ON CONFLICT(key) DO NOTHING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;draft_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slot already claimed, skipping: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt;
 &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drafter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE drafts SET body = ?, status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ready&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; WHERE key = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; plus &lt;code&gt;rowcount&lt;/code&gt; is the whole trick. The insert either wins or it does not, decided inside the database, and the loser never calls the model. There is no window to race because the claim is one statement.&lt;/p&gt;

&lt;p&gt;Two SQLite details make this behave under concurrency. Turn on WAL so a reader (a dashboard rendering the review queue) does not block the writer: &lt;code&gt;PRAGMA journal_mode=WAL&lt;/code&gt;. And set a busy timeout on every connection, so a writer that arrives during another write waits for the lock instead of raising immediately. Without the timeout, a claim that collides with an unrelated write surfaces as a locked-database error, and you will misread it as a bug in the claim logic.&lt;/p&gt;

&lt;p&gt;Keep writes short, too. Claim, release, do the slow network work outside any transaction, then reopen for the update. Holding a write transaction open across a model call is the fastest way to make a single-file database feel broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this approach stops helping
&lt;/h2&gt;

&lt;p&gt;It catches exact re-execution of the same unit of work. It does not deduplicate meaning. Bump the recipe and you get a new key and a genuinely new draft, even if the model produces text that reads almost the same as what is already sitting in review. Semantic near-duplicates are a different problem and a unique index will never see them.&lt;/p&gt;

&lt;p&gt;It leaves a half-finished state you have to handle. If the process dies between the claim and the update, you keep a claimed row with a null body, and the key is now taken, so retries skip it forever. You need a sweeper that finds claimed rows with no body older than a cutoff you choose and either resets them to unclaimed or deletes them. Pick that cutoff to be comfortably longer than your slowest model call, otherwise the sweeper starts stealing slots from runs that are still working.&lt;/p&gt;

&lt;p&gt;It does nothing about ordering or about throughput. Idempotency is not a queue. If you need retries with backoff, priorities, or workers on separate machines, you want a real job store, and at that point SQLite is not the right shared surface.&lt;/p&gt;

&lt;p&gt;And it does not solve the actual hard part of a content agent, which is the approval step. A clean, deduplicated queue that nobody reviews is still a pile of unpublished text. The value of the key is that it protects the reviewer's attention, which only matters if there is a place to review.&lt;/p&gt;

&lt;p&gt;If you want that half already running instead of assembled from scratch, I build and maintain the AI Content Agent Kit: a Python drafting agent and a Next.js approval dashboard in one docker-compose, MIT licensed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fulcrumenterprises.tech/go/content-agent-kit/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/content-agent-kit/?c=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>stdout is the protocol: what changes when an MCP server moves from stdio to HTTP</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/stdout-is-the-protocol-what-changes-when-an-mcp-server-moves-from-stdio-to-http-2mi4</link>
      <guid>https://dev.to/craig_solomon/stdout-is-the-protocol-what-changes-when-an-mcp-server-moves-from-stdio-to-http-2mi4</guid>
      <description>&lt;p&gt;On stdio transport, an MCP client starts your server as a child process and talks to it over stdin and stdout. That is the whole channel. Stdout is not "where your program prints things while the real protocol happens somewhere else." Stdout is the wire.&lt;/p&gt;

&lt;p&gt;Which means this tool is broken:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_tables&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
 &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;listing tables&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# this goes onto the JSON-RPC stream
&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&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;orders&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;The function returns the right value. The client still fails, because &lt;code&gt;listing tables\n&lt;/code&gt; arrived in the middle of a message stream that only expects framed JSON-RPC. Depending on the client you get a parse error, a tool that never appears, or a server that disconnects during startup with nothing useful on screen.&lt;/p&gt;

&lt;p&gt;The failure is confusing because the cause and the symptom are far apart. You added a debug line to a tool and the whole server stopped loading in Claude Desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one line, and it is about handlers, not about print
&lt;/h2&gt;

&lt;p&gt;Send everything human-readable to stderr:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;

&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StreamHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFormatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Formatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%(levelname)s %(name)s %(message)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_tables&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
 &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list_tables called&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&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;orders&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;&lt;code&gt;logging.basicConfig()&lt;/code&gt; already defaults to stderr, so you may think you get this for free. You do not, reliably. Whoever calls &lt;code&gt;basicConfig&lt;/code&gt; first wins, and if an imported module configures the root logger with &lt;code&gt;stream=sys.stdout&lt;/code&gt;, your log lines land on the protocol channel. Configure the handler explicitly in your entrypoint, before you import anything chatty.&lt;/p&gt;

&lt;p&gt;The other source of stray bytes is import-time output from a dependency: a banner, a deprecation notice someone routed through &lt;code&gt;print&lt;/code&gt;, a progress bar. For Python-level writes you can fence the import:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect_stdout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chatty_dependency&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;redirect_stdout&lt;/code&gt; swaps &lt;code&gt;sys.stdout&lt;/code&gt; for the duration of the block. It does not touch file descriptor 1, so a C extension that writes to fd 1 directly walks straight past it. If you hit that, you are into &lt;code&gt;os.dup2&lt;/code&gt; territory, and at that point it is usually easier to debug over HTTP and keep the dependency out of the stdio path.&lt;/p&gt;

&lt;p&gt;A useful habit while writing tools: treat &lt;code&gt;print&lt;/code&gt; as a syntax error in server code. Grep for it before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch itself is boring, which is the point
&lt;/h2&gt;

&lt;p&gt;Both transports run the same tool functions. Picking one is an environment decision, not a code decision:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP_TRANSPORT&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;stdio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streamable-http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streamable-http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stdio&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;Name the variable whatever you like. The value of doing it this way is that your local Claude Desktop setup and your deployed container are the same artifact, so a tool you debugged locally is the tool that runs remotely.&lt;/p&gt;

&lt;p&gt;What is not boring is everything the switch implies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when you stop being a subprocess
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The trust boundary moves.&lt;/strong&gt; On stdio, your server is a child process of the client, on the user's machine, running with the user's privileges. Nobody else can reach it, because there is nothing to reach. The only thing that drives it is the model sitting in front of one user. On streamable-http, you have a listening socket. Now the interesting question is who can open a connection to it, and the answer is no longer "only the person who launched it."&lt;/p&gt;

&lt;p&gt;That is the moment the input guards stop being a nicety. A file tool that resolves paths inside a sandbox root, a SQL tool that rejects anything but reads, an HTTP tool that checks the destination host against an allowlist: on stdio those protect a user from their own model. Over HTTP they protect you from anyone who can reach the endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process state becomes shared state.&lt;/strong&gt; One stdio process serves one client. One HTTP process serves many sessions at once. Module-level globals that felt harmless locally stop being harmless:&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="c1"&gt;# fine as a subprocess, a bug as a server
&lt;/span&gt;&lt;span class="n"&gt;CURRENT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;CURRENT_DIR&lt;/span&gt;
 &lt;span class="n"&gt;CURRENT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two callers, one variable. Write tools stateless: take the path, the query, the URL as an argument on every call, and derive everything else from it. If you need a cached resource like a database connection, make it per-request or make it thread-safe on purpose rather than by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking calls start costing other people.&lt;/strong&gt; Under stdio, a synchronous call that blocks for a while only makes one user wait. Under HTTP, it can stall concurrent requests. Use an async HTTP client for outbound calls, and push blocking work such as a synchronous database driver into a thread with &lt;code&gt;asyncio.to_thread&lt;/code&gt; instead of calling it directly inside an async handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug without the transport at all
&lt;/h2&gt;

&lt;p&gt;The best answer to "I cannot print, so how do I see what is happening" is to stop debugging through the client. Your tool functions are ordinary Python. Call them directly:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_read_text_file_rejects_escape&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
 &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="nf"&gt;read_text_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;../../etc/passwd&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;No transport, no client, no handshake, and the denial cases are the ones you actually want pinned down. When something misbehaves through Claude Desktop, checking whether the same call misbehaves in a test tells you immediately whether you have a tool bug or a transport bug. Those have completely different fixes and it is easy to spend an afternoon on the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;Routing logs to stderr is hygiene, not a feature. It stops a specific, repeatable crash. It does nothing for a tool that returns the wrong data.&lt;/p&gt;

&lt;p&gt;An env var that switches transports does not give you authentication, TLS, or rate limiting. &lt;code&gt;streamable-http&lt;/code&gt; makes the server reachable. Deciding who may reach it is a deployment question your &lt;code&gt;mcp.run&lt;/code&gt; call does not answer, and if you expose an endpoint on the open internet with no auth in front of it, per-call input guards are not going to save you.&lt;/p&gt;

&lt;p&gt;Those guards are also input validation, not authorization. They answer "is this call shaped like something allowed" and never "is this caller allowed to see this row." If different callers must see different data, that belongs in a layer that knows who the caller is.&lt;/p&gt;

&lt;p&gt;And if you only ever run inside Claude Desktop on your own laptop, the HTTP path may be work you do not need. The reason to wire it early is that retrofitting statelessness into tools written against a single-process assumption is more annoying than writing them stateless on day one.&lt;/p&gt;

&lt;p&gt;The MCP Starter Kit is the Python MCP server I built and maintain, with both transports behind one env var, the SSRF, SQL and path guards, and a pytest suite already in place: &lt;a href="https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>What has to be online for your artifact verification to pass?</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/craig_solomon/what-has-to-be-online-for-your-artifact-verification-to-pass-5c81</link>
      <guid>https://dev.to/craig_solomon/what-has-to-be-online-for-your-artifact-verification-to-pass-5c81</guid>
      <description>&lt;p&gt;I built ProofLedger, which anchors the SHA-256 hash of a file to Polygon and Bitcoin. Almost everything I got wrong while building it was on the verify side, not the anchor side, so that's what this post is about.&lt;/p&gt;

&lt;p&gt;Here's the question. When your provenance check goes green in CI, what did it have to reach to get there?&lt;/p&gt;

&lt;p&gt;Not "what does it verify." What does it &lt;em&gt;contact&lt;/em&gt;. Write down the hostnames. That list is the real trust boundary of your setup, and it's usually shorter than people expect and points at more parties than they expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split the claim into its parts first
&lt;/h2&gt;

&lt;p&gt;A timestamp or attestation check is a few separate assertions stacked into one exit code. Worth pulling them apart, because they have completely different failure modes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;These bytes hash to H.&lt;/li&gt;
&lt;li&gt;H was committed to something append-only at some point.&lt;/li&gt;
&lt;li&gt;That commitment is real, and I know it's real without the party who issued it telling me so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Part one is local and cheap. It's just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum &lt;/span&gt;dist/app.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No network, no auth, no vendor. If the digest doesn't match what the proof says, you're done, the rest doesn't matter. Any verifier that can't do part one without a round trip is doing something strange.&lt;/p&gt;

&lt;p&gt;Part two is a data structure question. Either the proof you're holding contains enough material to recompute the commitment, or it doesn't and you have to go ask.&lt;/p&gt;

&lt;p&gt;Part three is where the first two stop carrying you. A matching digest and a recomputable commitment both hold even if the commitment exists nowhere but the issuer's database.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It passes in CI" is not an answer to part three
&lt;/h2&gt;

&lt;p&gt;Green in CI tells you the verifier's dependencies were reachable and returned something the verifier liked. That's it. It doesn't tell you the proof is self-contained, and it doesn't tell you what green would have meant if one of those dependencies had been down.&lt;/p&gt;

&lt;p&gt;Two ways that bites.&lt;/p&gt;

&lt;p&gt;The first is fail-open. If a verifier treats an unreachable lookup as a soft warning instead of a hard failure, then a green check and a dead endpoint look identical from the outside. You will not notice, because the signal you're watching for is the absence of red. Go read the code path where the HTTP call errors. Does it &lt;code&gt;raise&lt;/code&gt;, or does it log and continue? I would check this before I checked anything else.&lt;/p&gt;

&lt;p&gt;The second is subtler. Suppose the lookup does work, and the only way to answer part three is an HTTPS call to the issuer's own API that comes back &lt;code&gt;{"valid": true}&lt;/code&gt;. That's not verification. That's the issuer's word, restated as JSON, with TLS making you feel better about the transport. If the issuer is also the party whose claim is being checked, you've built a loop. The proof is worth exactly as much as your trust in the company serving that endpoint, which may be plenty, but you should know that's what you bought.&lt;/p&gt;

&lt;p&gt;This is why the transparency-log and timestamp-authority conversations in the Sigstore world keep circling back to what a verifier is allowed to require at verify time, and what it does when that thing isn't there. Same question, different vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-contained actually means
&lt;/h2&gt;

&lt;p&gt;A proof is self-contained when you can get from the file on your disk to the anchored commitment using only material in the proof file, plus one lookup against something public.&lt;/p&gt;

&lt;p&gt;If the hash was anchored on its own, that's easy. Hash matches, commitment is the hash, one chain lookup and you're done.&lt;/p&gt;

&lt;p&gt;If hashes were batched, you need an inclusion path, and this is the part worth understanding even if you never touch my product, because it's the same mechanic everywhere hashes get batched. The proof hands you the sibling hashes along the path from your leaf to the root, in order. You concatenate and hash, step by step, and you either land on the recorded root or you don't. Nothing in that computation needs a server. The sibling hashes are just bytes. The ordering matters and is part of the proof, and if the format doesn't pin the ordering, the proof is ambiguous and you should say so out loud.&lt;/p&gt;

&lt;p&gt;What you cannot derive locally is the root that's actually on chain. That's the one lookup, and it should point at a public chain rather than at the issuer. That's the entire reason I shipped a Python package, &lt;code&gt;verify-proof&lt;/code&gt;, that does the check offline and locally, plus a GitHub Action for CI. The public verify URL exists too, and the REST API has a public endpoint with no auth so an auditor or opposing counsel can hit it themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://proofledger.io/api/v1/verify?hash=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sha256sum &lt;/span&gt;dist/app.tar.gz | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint is rate limited to 120 requests per hour per IP, which is fine for a human checking a claim and not the thing to build a CI fleet on. The local package is the path for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bottoms out, honestly
&lt;/h2&gt;

&lt;p&gt;Offline verification does not mean you conjure chain state out of nothing. It means you can re-check without the issuer. You still have to have obtained the anchored root at some point, from a node you run, a block explorer, or a copy you pinned when you first received the proof. If you never do that, you're trusting whoever handed you the root. Local verification moves the trust from "the vendor's API right now" to "the chain record I can check from any source," which is a real improvement and not a magic one.&lt;/p&gt;

&lt;p&gt;Other limits worth stating plainly. A timestamp proves the bytes existed by a certain point. It says nothing about who made them, whether they're correct, or whether the thing they describe is true. Anchoring establishes an upper bound on age and nothing else.&lt;/p&gt;

&lt;p&gt;And I'm not claiming my Bitcoin proof is better than a free one. OpenTimestamps gives you free Bitcoin timestamping and the resulting proof is cryptographically just as valid as mine. What I sell is the platform around the proof. The proof itself is the same math available to anyone. On my side, Polygon anchoring is free and unlimited on every plan including the free one, and Bitcoin anchoring is billed per anchor because each one costs me a transaction. I'd rather say that than dress it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, run it today
&lt;/h2&gt;

&lt;p&gt;Take an artifact you already have provenance for. Not a toy file, a real one from a real build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test one.&lt;/strong&gt; Run your existing verify step with no network at all and record the exit code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; none &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/work"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /work my-ci-image &lt;span class="se"&gt;\&lt;/span&gt;
 ./verify.sh dist/app.tar.gz
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it passes, ask what it actually checked. If it fails, read the error and find out which of the three parts above it failed on. Either answer teaches you something. The bad outcome is a warning line and a zero exit code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test two.&lt;/strong&gt; Append a byte and verify the tampered copy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;dist/app.tar.gz /tmp/tampered.tar.gz
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\x00'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /tmp/tampered.tar.gz
./verify.sh /tmp/tampered.tar.gz&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A verifier that rejects this offline is doing part one correctly. A verifier that needs the network to notice a flipped byte is worth a closer look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test three.&lt;/strong&gt; Feed your verifier a hash that was never anchored. You want an unambiguous "no record," not an empty success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test four.&lt;/strong&gt; Hand the proof to someone outside your org and ask them to check it without talking to you or to your vendor's support. If they can't, the proof isn't portable, and portability is the only property that matters when the check happens somewhere you aren't.&lt;/p&gt;

&lt;p&gt;That last one is the question I'd actually like practitioners to answer: when you hand a provenance artifact to an outside party, what do they have to install or trust before they can check it themselves? I have my answer for my own thing. I'm interested in what other formats require in practice.&lt;/p&gt;

&lt;p&gt;If you want to poke at mine, the API docs are at proofledger.io/api.html and the OpenAPI spec is at /openapi.json. &lt;code&gt;verify-proof&lt;/code&gt; on PyPI is the part you can run without me.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>cicd</category>
      <category>python</category>
    </item>
    <item>
      <title>Hard-failing invented numbers in AI-drafted copy</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/hard-failing-invented-numbers-in-ai-drafted-copy-4h84</link>
      <guid>https://dev.to/craig_solomon/hard-failing-invented-numbers-in-ai-drafted-copy-4h84</guid>
      <description>&lt;p&gt;A model asked to write promotional copy will eventually produce a figure nobody gave it. Not always, and not obviously. It rounds something it saw upstream, or it fills a slot because the sentence shape wanted a number there, and the result sits in the paragraph looking exactly as credible as the true figures around it. Fabricated numbers are the one class of error that gets harder to spot as the prose gets better.&lt;/p&gt;

&lt;p&gt;"Do not invent numbers" in the system prompt is a preference. A function that runs after generation and refuses to return the draft is a guard. This post is about the guard: how to key numeric tokens so the comparison actually means something, where the normalization breaks, and what the check still cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check is a set difference, and the hard part is the key
&lt;/h2&gt;

&lt;p&gt;The shape is simple. You have a fact sheet for the thing being written about. You extract every number from the fact sheet, extract every number from the draft, and fail on anything in the second set that is not in the first.&lt;/p&gt;

&lt;p&gt;That only works if both sides are reduced to the same canonical form. &lt;code&gt;1,000&lt;/code&gt; and &lt;code&gt;1000&lt;/code&gt; are the same claim. &lt;code&gt;9&lt;/code&gt; and &lt;code&gt;nine&lt;/code&gt; are the same claim. &lt;code&gt;$90&lt;/code&gt; and &lt;code&gt;90%&lt;/code&gt; are emphatically not, even though the digits match. So the key has to carry the unit, and the same normalizer has to run over both the fact sheet and the draft.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvalidOperation&lt;/span&gt;

&lt;span class="n"&gt;WORD_NUMBERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zero one two three four five six seven eight nine ten eleven twelve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?P&amp;lt;currency&amp;gt;[$€£])?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?P&amp;lt;value&amp;gt;\d[\d,]*(?:\.\d+)?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s?(?P&amp;lt;unit&amp;gt;%|percent\b)?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&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="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;InvalidOperation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;numbers_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WORD_NUMBERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.,;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
 &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
 &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unsupported_numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fact_sheet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;numbers_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fact_sheet&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;numbers_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word-number pass runs before the regex, so spelled-out figures get the same treatment as digits. Running it as a token-level substitution rather than a regex on the raw string keeps it from mangling words that contain a number word.&lt;/p&gt;

&lt;p&gt;Here it is against a fact sheet and a draft that mostly behaves:&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;FACTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
9 channel definitions, each with its own voice rules and link discipline.
A (product, channel) pair may auto-publish only after 10+ reviewed drafts at 90%+ approval, only on free-API channels, only with the master switch on and credentials set.
MIT licensed.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;DRAFT&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;It ships nine channel definitions, holds a 90% approval bar &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before anything posts itself, and returns $90 of value a month.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;unsupported_numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DRAFT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FACTS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ['$90']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nine&lt;/code&gt; passes because it normalizes to the &lt;code&gt;9&lt;/code&gt; in the fact sheet. &lt;code&gt;90%&lt;/code&gt; passes on an exact key match. &lt;code&gt;$90&lt;/code&gt; fails, and it fails for the right reason: &lt;code&gt;90&lt;/code&gt; is in the fact sheet, but as a percentage, and the currency-keyed form has no entry. This is the case a naive digit-scan waves through, and it is the most dangerous one, because a number lifted from a true fact into a false unit is the fabrication a human reviewer is least likely to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail, do not fix
&lt;/h2&gt;

&lt;p&gt;Once the check works you have a policy question: when a draft trips it, do you repair the draft or throw it away?&lt;/p&gt;

&lt;p&gt;Repairing numbers is a trap. Deleting the offending token leaves a sentence making the same claim with a hole in it, and asking the model to fix the figure invites a second invented one. So the number check hard-fails. The draft does not get published, does not get partially edited, and does not get argued with in code.&lt;/p&gt;

&lt;p&gt;A small number of violations are safe to fix mechanically, because the fix carries no semantics. Typographic normalization is the clear case: you can strip characters you never want in output and move on, with no retry and no model in the loop, because removing a dash changes no claim. Keep that list short and keep it strictly separate from the fail list. The rule I would hold to is that anything which could change the meaning of a sentence fails; anything that provably cannot gets sanitized.&lt;/p&gt;

&lt;p&gt;Ordering matters too. Sanitize first, then validate, so the validator sees exactly the bytes that would ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the fact sheet the only source of both
&lt;/h2&gt;

&lt;p&gt;The reason this holds up over time is not the regex. It is that the allowlist is derived from the same structured fact sheet used to build the prompt. One file per product, read by the generator and read by the validator. If you maintain the prompt facts in one place and the validator's permitted figures in another, they drift, and the drift shows up as false failures until someone loosens the check to make the noise stop.&lt;/p&gt;

&lt;p&gt;Two more things worth wiring in while you are here, both plain engineering rather than anything clever. Log the reason for every rejection in a form you can read back, and feed recent rejection reasons for that product and channel into the next generation attempt, so the same mistake costs you less each time. And keep the validator flags visible on whatever surface you use to review drafts, so a human approving something can see what the check looked at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not catch
&lt;/h2&gt;

&lt;p&gt;The honest limits, because they decide whether the check is worth building for your case.&lt;/p&gt;

&lt;p&gt;It only sees numbers. A draft claiming an integration that does not exist, a customer you do not have, or a capability you never shipped passes cleanly. Those need their own guards, and some of them need a person.&lt;/p&gt;

&lt;p&gt;It sees numbers, not relations. Every figure can be present in the fact sheet and the sentence can still be false: a true count attached to the wrong noun, a true percentage attributed to the wrong condition, a real figure placed in a comparison you never measured. The check confirms provenance of tokens, not truth of sentences.&lt;/p&gt;

&lt;p&gt;Magnitude suffixes and ranges need explicit handling. &lt;code&gt;2k&lt;/code&gt;, &lt;code&gt;2 thousand&lt;/code&gt;, and &lt;code&gt;2,000&lt;/code&gt; are one claim in three spellings, and a range like &lt;code&gt;10 to 20&lt;/code&gt; is two tokens that may both be absent from the fact sheet even when the range is fine. The version above handles none of that. Extend the canonicalizer, do not extend the allowlist.&lt;/p&gt;

&lt;p&gt;Word-number expansion buys false positives. "One of the channels" becomes a bare &lt;code&gt;1&lt;/code&gt; in the scan. You can narrow it by only expanding word numbers directly preceding a noun, or you can accept the noise and let the fact sheet carry the small integers. Either choice has a cost, and the choice with no cost does not exist.&lt;/p&gt;

&lt;p&gt;Carve-outs are where fabrications hide. Skipping code blocks, URLs, and version strings makes the check usable, and every skipped region is a place an invented figure can sit. If you exclude something, exclude it narrowly.&lt;/p&gt;

&lt;p&gt;And if your copy carries no figures at all, this guard buys you very little. It earns its keep specifically when a generator writes about things with counts, prices, and percentages attached, and when the cost of one wrong figure under your name is higher than the cost of maintaining a fact sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content Agent Pro is the drafting engine I built and run on my own store, with deterministic validators including this one, the reject-reason feedback loop, and per-channel graduation gates already wired together: &lt;a href="https://fulcrumenterprises.tech/go/content-agent-kit-pro/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/content-agent-kit-pro/?c=devto&lt;/a&gt;
&lt;/h2&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
      <category>automation</category>
    </item>
    <item>
      <title>The allowlist in your MCP call_api tool should match hosts, not strings</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Sun, 27 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/the-allowlist-in-your-mcp-callapi-tool-should-match-hosts-not-strings-294n</link>
      <guid>https://dev.to/craig_solomon/the-allowlist-in-your-mcp-callapi-tool-should-match-hosts-not-strings-294n</guid>
      <description>&lt;p&gt;The moment you expose a tool like this over MCP, the URL stops being yours:&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="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Call a REST API and return the response body.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
 &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;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;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model fills in &lt;code&gt;url&lt;/code&gt;. Your server makes the request, from inside your network, with whatever egress your process has. That is a request forwarder with a language model at the wheel, which is the classic shape of a server-side request forgery bug. The difference from a normal SSRF is that the attacker input does not have to arrive in an HTTP parameter. It can arrive in a document the model read, a webpage it fetched a second ago, or a support ticket it was asked to summarize.&lt;/p&gt;

&lt;p&gt;So you add an allowlist. Here is the one I wrote for my own server, and what I had to stop it from doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The string check that does not hold
&lt;/h2&gt;

&lt;p&gt;The first instinct is to check the text of the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host not allowed&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 URLs pass that check and none of them go where you think.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.example.com.attacker.tld/collect
https://api.example.com@attacker.tld/collect
https://api.example.com.attacker.tld/../../whatever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is a suffix trick: &lt;code&gt;api.example.com.attacker.tld&lt;/code&gt; is a hostname under &lt;code&gt;attacker.tld&lt;/code&gt;, and it starts with your allowed string. The second is the userinfo field. Everything before the &lt;code&gt;@&lt;/code&gt; in the authority is a username, so the host is &lt;code&gt;attacker.tld&lt;/code&gt; and &lt;code&gt;api.example.com&lt;/code&gt; is just a decorative login name. A &lt;code&gt;"api.example.com" in url&lt;/code&gt; check is worse again, because the substring can live in the query string of a completely different URL.&lt;/p&gt;

&lt;p&gt;The fix is to stop reasoning about the URL as text and let a parser tell you what the host actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse, then compare
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlsplit&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED_HOSTS&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;api.example.com&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;api.internal-billing.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;ALLOWED_SCHEMES&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;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_SCHEMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scheme not allowed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_HOSTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host not allowed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;urlsplit(...).hostname&lt;/code&gt; is doing real work here. It strips the userinfo, strips the port, and lowercases the result, so &lt;code&gt;HTTPS://API.EXAMPLE.COM@evil.tld/&lt;/code&gt; gives you &lt;code&gt;evil.tld&lt;/code&gt; and the check fails where it should.&lt;/p&gt;

&lt;p&gt;The scheme check matters as much as the host check. Without it, &lt;code&gt;file:///etc/shadow&lt;/code&gt; has no host at all, and depending on your client stack a scheme you never considered can still open something. Allow the schemes you actually use and reject the rest by default.&lt;/p&gt;

&lt;p&gt;Set membership is an exact match, which is the behaviour you want. If you need subdomains, write the rule out rather than reaching for a substring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;host_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be honest about what that widens to. Anyone who can create a subdomain on that parent domain is now inside your allowlist, and on a large SaaS provider that can be anyone with an account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layers you can add on top
&lt;/h2&gt;

&lt;p&gt;The host allowlist is the boundary. A few standard-library techniques harden the path around it, and you can bolt each of these on independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reject non-public addresses.&lt;/strong&gt; If your allowlist is generated from config and someone adds a wildcard, or you allow a host you do not control, resolving before you connect catches the address ranges that hurt: loopback, private ranges, and the link-local range where cloud instance metadata lives.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_resolves_public&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sockaddr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proto&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IPPROTO_TCP&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sockaddr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
 &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_loopback&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_link_local&lt;/span&gt;
 &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_reserved&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_multicast&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; resolves to a non-public address: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&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;&lt;code&gt;ipaddress&lt;/code&gt; handles the encodings by hand-rolled checks miss. IPv4 written in decimal, IPv6, and IPv4-mapped IPv6 all parse into an object whose &lt;code&gt;is_private&lt;/code&gt; and &lt;code&gt;is_loopback&lt;/code&gt; properties tell you the truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not follow redirects blindly.&lt;/strong&gt; An allowed host that returns a redirect can hand the request to a host you never allowed, and most HTTP clients will follow it for you by default. Turn that off and re-run the check on every hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;hops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
 &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;check_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&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;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
 &lt;span class="n"&gt;hops&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect limit exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&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="n"&gt;next_request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client is constructed with &lt;code&gt;follow_redirects=False&lt;/code&gt; so the loop above is the only thing moving between hops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep credentials out of the model's reach.&lt;/strong&gt; If the tool signature takes a &lt;code&gt;headers&lt;/code&gt; dict, the model can set &lt;code&gt;Authorization&lt;/code&gt; on a request to any allowed host, and it can also fail to set it. Injecting auth server side, keyed on the resolved host, means the token never appears in a tool argument and never appears in a transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;A host allowlist controls where the request goes. It controls nothing else, and the gaps are worth naming.&lt;/p&gt;

&lt;p&gt;It does not close the gap between the check and the connection. You resolve a name, you decide it is fine, and then the HTTP client resolves it again when it opens the socket. A DNS record with a short TTL can answer differently on the second lookup, which is DNS rebinding. Closing that means pinning the address you validated and connecting to that address directly, which is a transport-level change, not a text-level one.&lt;/p&gt;

&lt;p&gt;It does not stop exfiltration to an allowed host. If your allowlist includes an API that echoes a path or accepts arbitrary POST bodies, data can leave through it. Path and method restrictions are a separate control.&lt;/p&gt;

&lt;p&gt;It does not make the response safe. Whatever comes back is going into the model's context, and an allowed host serving attacker-influenced content is a prompt injection channel. Treat tool output as untrusted input to the next turn.&lt;/p&gt;

&lt;p&gt;And it is the wrong tool if your product genuinely needs to fetch user-supplied URLs across the open internet. An allowlist cannot enumerate the internet. That case belongs to an egress proxy or a network policy, with the fetcher running somewhere that cannot reach anything internal in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the denials
&lt;/h2&gt;

&lt;p&gt;The check above has one job and it is a refusal. Write the test file as the list of things that must be rejected: the suffix host, the userinfo host, the non-HTTPS scheme, the &lt;code&gt;file://&lt;/code&gt; URL, the redirect to an off-list host. A passing request proves very little. A rejected one proves the boundary exists.&lt;/p&gt;

&lt;p&gt;The MCP server I built and run packages this host allowlist together with a read-only SQL guard and a path-traversal sandbox as a Python template you can clone: &lt;a href="https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>mcp</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>What survives if the company that issued your timestamp disappears</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/what-survives-if-the-company-that-issued-your-timestamp-disappears-2k3k</link>
      <guid>https://dev.to/craig_solomon/what-survives-if-the-company-that-issued-your-timestamp-disappears-2k3k</guid>
      <description>&lt;p&gt;Here's the question I'd ask about your own release pipeline, and it isn't "is the timestamp signed."&lt;/p&gt;

&lt;p&gt;It's this: if the service that issued your timestamps went dark tonight, what could you still prove in the morning?&lt;/p&gt;

&lt;p&gt;The answer doesn't depend on what the service promised. It depends entirely on what you kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious answer, and why it falls over
&lt;/h2&gt;

&lt;p&gt;Obvious answer: you kept the verify link. Every timestamping service hands you one. You stash it in the release notes, you move on.&lt;/p&gt;

&lt;p&gt;But a verify URL is a request to the issuer. That's the whole thing it is. If the issuer is gone, that URL is a 404, and what you're left holding is a screenshot of a page that used to load. A screenshot is not evidence of anything except that you once had a browser.&lt;/p&gt;

&lt;p&gt;So the link is fine as a convenience. It is not the proof. Worth separating those two in your head before you go further.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a timestamp actually consists of
&lt;/h2&gt;

&lt;p&gt;Strip it down and there are two independent pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The binding.&lt;/strong&gt; A hash ties a specific sequence of bytes to a short digest. You run SHA-256 over the file, you get a digest, and anyone who has the same bytes gets the same digest. Forever. No service is involved in that step and none can be. This is the part you already control, and keeping it costs you nothing beyond keeping the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The anchor.&lt;/strong&gt; Something append-only and expensive to rewrite says "this digest was known by this point." That's the part you're renting from somebody, and that's the part that goes away when they do.&lt;/p&gt;

&lt;p&gt;So the anchor has to end up on your disk as data you hold, not as a pointer into someone else's database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part you have to store yourself
&lt;/h2&gt;

&lt;p&gt;Here's what you actually need on your own disk for the anchor to survive the issuer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The digest. You can always recompute it from the file, but store it anyway, because it's tiny and it tells you which file you meant.&lt;/li&gt;
&lt;li&gt;Which chain the anchor went to.&lt;/li&gt;
&lt;li&gt;The transaction id on that chain.&lt;/li&gt;
&lt;li&gt;The path from your digest to whatever that transaction committed to.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Number four is the one that gets dropped, and it's the one that matters.&lt;/p&gt;

&lt;p&gt;Anchoring one hash per transaction is expensive, so batching is normal. Many digests go into a Merkle tree, the root of that tree goes on chain, and the transaction commits to the root, not to your digest. Which means the on-chain record says nothing about your file on its own. Your digest plus the root is not a proof. You need the sibling hashes along the path from your leaf up to the root, in order, with the left/right position of each step.&lt;/p&gt;

&lt;p&gt;With those, verification is arithmetic you can do yourself:&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;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;leaf&lt;/span&gt;
&lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sibling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sibling&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sibling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;root_in_transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That loop is the entire proof. It needs no network, no account, no vendor. If you're storing a verify URL instead of that path, you don't have a portable proof, you have a bookmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two chains, one digest, and an honest caveat
&lt;/h2&gt;

&lt;p&gt;ProofLedger, which is mine, sends the same digest to Polygon and to Bitcoin. Same hash, two independent records. The reason is redundancy of the anchor, not strength of the cryptography.&lt;/p&gt;

&lt;p&gt;I want to be blunt about that second part because it's where this category gets oversold. OpenTimestamps gives you Bitcoin timestamping for free and the proof it produces is cryptographically just as valid as mine. There is no version of this where I have a better Bitcoin proof than a free tool. A hash committed to a chain is a hash committed to a chain. What differs between options is the platform wrapped around it: what you can query, what you can export, what the verification path looks like for someone who doesn't trust you. Anyone selling you a stronger proof is selling you an adjective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification has to be boring, and it has to be public
&lt;/h2&gt;

&lt;p&gt;The test of whether a timestamp is worth anything is whether the person relying on it can check it without asking you. Not without asking the vendor. Without asking &lt;em&gt;you&lt;/em&gt;, the party with the motive.&lt;/p&gt;

&lt;p&gt;That's why the verify endpoint on my API takes no auth at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 release-artifact.tar.gz | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://proofledger.io/api/v1/verify?hash=&lt;/span&gt;&lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no account, 120 requests per hour per IP. An auditor or opposing counsel can run that from a laptop. The whole v1 API is three endpoints: POST /api/v1/proof to submit a hash with a Bearer sk_ key, GET /api/v1/proof/:id for status plus chain_tx and bitcoin_txid, owner only, and that public verify. There's an OpenAPI spec at /openapi.json if you'd rather generate a client than read docs.&lt;/p&gt;

&lt;p&gt;The file itself never goes anywhere. You hash locally, you send the digest. That's not a privacy feature bolted on, it's just what the engine does, because the engine only ever sees a digest. It has no idea what kind of file you hashed and there's no per-file-type pipeline behind it.&lt;/p&gt;

&lt;p&gt;For the offline case there's a &lt;code&gt;verify-proof&lt;/code&gt; package on PyPI that does the same check locally, and a GitHub Action so CI can do it on every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits, stated plainly
&lt;/h2&gt;

&lt;p&gt;Reading a chain means reading a chain. Local verification confirms your bytes match your proof material without involving me, but confirming the anchor itself still means somebody queries a node or an explorer. That's a real dependency and I'd rather name it than let "offline" do work it can't do.&lt;/p&gt;

&lt;p&gt;A timestamp proves existence by a point in time. It does not prove authorship, it does not prove the contents are true, and it does not prove you're the one who made the file. It proves the bytes existed. That's a narrow claim and narrow is what makes it hold.&lt;/p&gt;

&lt;p&gt;And a tradeoff I took that I'm not thrilled about: the Merkle-inclusion download sits on the Business tier. Polygon anchoring is free and unlimited on every plan including the free one, and every plan has API access with a monthly cap, but that particular export is gated. That's a business decision, not a technical one, and I'd rather say so than have you find out later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run today
&lt;/h2&gt;

&lt;p&gt;Pick an artifact you shipped and still care about. Then try to verify its timestamp using only files on your own disk and a public block explorer. No vendor login, no support ticket, no dashboard.&lt;/p&gt;

&lt;p&gt;Can you get from the bytes to a confirmed on-chain record by yourself? If yes, you have a timestamp. If you have to authenticate to somebody first, you have an account with a company that has a timestamp.&lt;/p&gt;

&lt;p&gt;Run that against whatever you're using now, including mine. proofledger.io, API docs at proofledger.io/api.html. I build and run it.&lt;/p&gt;

&lt;p&gt;Genuine question for anyone doing this at scale already: what do you actually store next to your artifacts, just the digest and a txid, or the full inclusion path? I keep going back and forth on what belongs in the repo versus what belongs in cold storage, and I'd like to hear how other people landed on it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Turning thumbs up and down into keyword weight proposals</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Fri, 25 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/turning-thumbs-up-and-down-into-keyword-weight-proposals-16ce</link>
      <guid>https://dev.to/craig_solomon/turning-thumbs-up-and-down-into-keyword-weight-proposals-16ce</guid>
      <description>&lt;p&gt;A keyword scorer is the cheapest filter you can put in front of an LLM. You write terms, you give them weights, you sum the matches, and anything over a threshold gets read by the expensive model. It works on day one. The problem starts on day thirty, when your product has moved and your term list has not.&lt;/p&gt;

&lt;p&gt;The fix is not to rewrite the list by hand every month. It is to capture the feedback you are already collecting and turn it into weight proposals you can approve or reject. Here is the mechanism, including the part that breaks if you skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log attribution at scoring time, not later
&lt;/h2&gt;

&lt;p&gt;This is the step that decides whether any of the rest is possible.&lt;/p&gt;

&lt;p&gt;When you score an item, you know exactly which terms fired and what each one contributed. If you store only the final score, that knowledge is gone, and later you are reduced to re-running a guess about which term earned the hit.&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="c1"&gt;# weights: term -&amp;gt; {"dimension": "threat", "weight": 3.0}
&lt;/span&gt;&lt;span class="n"&gt;DIMENSIONS&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;opportunity&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;adoption&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;pivot&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;threat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DIMENSIONS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dimension&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
 &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dimension&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist &lt;code&gt;hits&lt;/code&gt; next to the item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;signal_terms&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;item_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="nb"&gt;REAL&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a thumbs up or down on an item is a labelled example for every term that fired on it. Without this table you have a rating on a piece of text and no way to attach it to anything you can tune.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split the credit, do not hand it out twice
&lt;/h2&gt;

&lt;p&gt;The naive aggregation gives every term that fired a full vote. That quietly rewards your broadest terms. A generic word that appears on almost everything gets credited for every good item your sharp terms actually found, and its weight creeps up until it dominates the score.&lt;/p&gt;

&lt;p&gt;Split the credit across the terms that fired on that item, proportional to what each contributed:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;term_feedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;rows: (item_id, term, weight, verdict) with verdict in {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
 &lt;span class="n"&gt;by_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;by_item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

 &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;down&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
 &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;
 &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Votes are now fractional, which is the honest representation. A term that was one of several reasons an item scored is one of several reasons it was good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smooth before you rank
&lt;/h2&gt;

&lt;p&gt;A term with one thumbs down and nothing else has a raw precision of zero. Act on that and you will delete a good term because of a single bad morning.&lt;/p&gt;

&lt;p&gt;Add a prior, then require a minimum amount of evidence before a term is eligible for a proposal at all:&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;PRIOR_UP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PRIOR_DOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PRIOR_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PRIOR_UP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PRIOR_DOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prior pulls thin evidence toward the middle, so terms with little feedback sit in the do-nothing band on their own. The minimum-evidence gate is still worth having, because it makes the reason a term was skipped explicit in the code instead of implicit in the arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Propose a diff, do not apply one
&lt;/h2&gt;

&lt;p&gt;Here is the design decision worth arguing about: the tuner should emit a proposal, not a write.&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;MIN_EVIDENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;
&lt;span class="n"&gt;STEP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
&lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CEIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;
&lt;span class="n"&gt;UP_BAND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DOWN_BAND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;propose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
 &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_EVIDENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;continue&lt;/span&gt;
 &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;UP_BAND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;STEP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CEIL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;DOWN_BAND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;STEP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="k"&gt;continue&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
 &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;term&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;precision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;precision&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 reasons to keep a human in the loop.&lt;/p&gt;

&lt;p&gt;Keyword weights are coupled. Move several at once and you have not adjusted terms, you have moved the whole score distribution relative to your threshold. A reviewer looking at the batch notices that. A loop applying its own output does not.&lt;/p&gt;

&lt;p&gt;Feedback is an opinion about relevance, and relevance is a business judgement. The person who knows whether "seed round" should matter more this quarter is the person reading the briefing, not the aggregator.&lt;/p&gt;

&lt;p&gt;An auto-applying loop has no audit trail. When scoring goes strange, you want a list of accepted proposals to walk backwards through, not a weights file that has been drifting on its own.&lt;/p&gt;

&lt;p&gt;The fixed step and the floor and ceiling do the rest of the safety work. No single round can move a term far, and nothing can decay to zero or run away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop can measure precision and not recall
&lt;/h2&gt;

&lt;p&gt;Your feedback is not a random sample. You only see thumbs on items that survived the filter and got delivered. Every item the keyword pass dropped is invisible, so the loop can measure precision and cannot measure recall at all.&lt;/p&gt;

&lt;p&gt;Run it long enough and the tuner sharpens the terms you already have while the things you never wrote down stay unreachable. Precision goes up. Coverage quietly narrows.&lt;/p&gt;

&lt;p&gt;The cheap mitigation is a holdout. Route a small random sample of below-threshold items into a review queue on a schedule, label them like anything else, and look at what comes back marked good. Those items are the only direct evidence you get about what your term list is missing. Weight tuning cannot fix a missing term; only a human reading a near-miss can add it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;It does not invent vocabulary. A new competitor's name, a new framework, a term of art that appeared last week: none of that arrives from tuning weights on terms you already wrote.&lt;/p&gt;

&lt;p&gt;It drifts toward whatever you happen to click. If you only rate the items that annoy you, you are training an annoyance detector.&lt;/p&gt;

&lt;p&gt;It needs a cold start. Until terms clear the evidence gate, the tuner correctly proposes nothing, and a system that appears to do nothing for a while is a system people stop feeding.&lt;/p&gt;

&lt;p&gt;And if you will not give feedback, skip the whole thing. A tuner with no labels is a scheduled job that reads an empty table. Static weights you revise by hand are a perfectly reasonable alternative, and honest about what they are.&lt;/p&gt;

&lt;p&gt;This auto-tuning loop is one piece of the Market Radar Kit, a self-hosted market-intelligence agent I built and run in Docker against my own Claude subscription: &lt;a href="https://fulcrumenterprises.tech/go/market-radar-kit/?c=devto" rel="noopener noreferrer"&gt;https://fulcrumenterprises.tech/go/market-radar-kit/?c=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>sqlite</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
