<?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%2F053d9cc9-a8a8-43f8-ba52-6f2902d940e6.jpg</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>Your timestamp proves "no later than." What proves "no earlier than"?</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Thu, 15 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/your-timestamp-proves-no-later-than-what-proves-no-earlier-than-54m0</link>
      <guid>https://dev.to/craig_solomon/your-timestamp-proves-no-later-than-what-proves-no-earlier-than-54m0</guid>
      <description>&lt;p&gt;When you timestamp a file, what does that timestamp actually say about &lt;em&gt;when the file was made&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;The obvious answer is "when it was made." That's wrong, and fixing it changes how you set up your pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an anchored hash actually proves
&lt;/h2&gt;

&lt;p&gt;Here's the mechanism. You hash the file:&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;release-v2.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That digest goes into a transaction, and the transaction lands in a block. Anyone can later recompute the hash of your file, find it in that block, and read the block's time.&lt;/p&gt;

&lt;p&gt;So what have you proven? That this exact sequence of bytes existed &lt;strong&gt;no later than&lt;/strong&gt; that block. Someone had to know the hash to put it on chain, and you can't know a SHA-256 digest without having the bytes. That part holds up, and it doesn't depend on trusting whoever submitted it.&lt;/p&gt;

&lt;p&gt;But look at what it doesn't say.&lt;/p&gt;

&lt;p&gt;It doesn't say the file was created at that moment. It doesn't say it was created &lt;em&gt;near&lt;/em&gt; that moment. The file could have been sitting on a disk for a long time before anyone hashed it. A timestamp is an upper bound. It closes off one side of the timeline and leaves the other side wide open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the open side matters
&lt;/h2&gt;

&lt;p&gt;For a lot of uses the upper bound really is all you need. "This build artifact existed before the incident." "This dataset existed before the model was trained on it." "I had this design before you published yours." In every one of those, earlier is fine. Nobody's hurt if your file turns out to be older than the proof says.&lt;/p&gt;

&lt;p&gt;Now flip it. Say you're trying to show something was produced &lt;em&gt;after&lt;/em&gt; a point. A log that was written after a deploy and not prepared beforehand. A test report that reflects the code as it stood after a fix, not one generated earlier and held in reserve. A prediction that you want to show was made fresh, not pre-written in several versions with only the winner anchored.&lt;/p&gt;

&lt;p&gt;An anchored hash says nothing about any of those. If I can prepare ten versions of a document today and anchor whichever one turns out right next week, the anchor proves the winning version existed next week. True, and useless.&lt;/p&gt;

&lt;p&gt;So the honest version of what a timestamp gives you is narrower than the word suggests. You get one bound. Whether that's enough depends entirely on which direction the person checking it cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the other bound
&lt;/h2&gt;

&lt;p&gt;The trick is old and it's simple: put something unpredictable from a known moment &lt;em&gt;inside&lt;/em&gt; the file before you hash it.&lt;/p&gt;

&lt;p&gt;A recent block hash works well, because nobody can know it before that block exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# grab the current best block hash from a Bitcoin node you run&lt;/span&gt;
&lt;span class="nv"&gt;BLOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bitcoin-cli getbestblockhash&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# write it into the artifact itself, then hash&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"anchor-after: &lt;/span&gt;&lt;span class="nv"&gt;$BLOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; build-report.txt
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;build-report.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the file carries its own lower bound. It contains a value that didn't exist until that block was mined, so the file can't be older than that block. Anchor the result and you've got the upper bound too. The file existed somewhere inside a window, and both edges of the window can be checked by a stranger without asking you.&lt;/p&gt;

&lt;p&gt;That's the step that turns "a timestamp" into "a time range someone can rely on."&lt;/p&gt;

&lt;p&gt;A couple of things to be careful with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The value has to be inside the bytes you hash. Putting it in a filename, a database row next to the file, or a metadata field your hashing step skips gets you nothing. If it isn't in the hash, it isn't in the proof.&lt;/li&gt;
&lt;li&gt;The value has to be unpredictable. A date string doesn't work. Anyone can type tomorrow's date. It needs to be something nobody could have known in advance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ProofLedger doesn't do this for you, for what it's worth. It hashes whatever bytes you hand it and doesn't care what's inside them. That was a deliberate choice: one engine, no file-type pipelines. The cost is that the lower bound is your job. If your format can't hold an extra line, wrap it. Put the original file and a small manifest with the block hash in an archive, and hash the archive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing the window from the other side
&lt;/h2&gt;

&lt;p&gt;The upper bound has slack in it too, and the slack is the time between "the file was finished" and "the hash landed in a block."&lt;/p&gt;

&lt;p&gt;Part of that is the chain. A block's timestamp isn't an atomic clock reading. Consensus rules give miners some leeway, so read it as approximate, not to the second.&lt;/p&gt;

&lt;p&gt;Most of it, though, is your own process. If an artifact gets built in CI and somebody anchors it by hand later, the window is however long "later" turned out to be. That's why I'd anchor at the point of production, in the same job that builds the thing. ProofLedger's API has one endpoint for that, &lt;code&gt;POST /api/v1/proof&lt;/code&gt;, which takes a hash with a Bearer &lt;code&gt;sk_&lt;/code&gt; key. There's also a GitHub Action for CI/CD if you'd rather not script the call yourself.&lt;/p&gt;

&lt;p&gt;Dual anchoring helps a bit here too. The same hash goes to Polygon and to Bitcoin, so you get two independently checkable upper bounds. Each one stands alone, and whichever confirms first is the tighter one. It also means a checker isn't relying on a single chain to read the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the time on your proof comes from
&lt;/h2&gt;

&lt;p&gt;One more thing worth checking, because it's easy to blur. A timestamping service can show you several different "times":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when you clicked submit&lt;/li&gt;
&lt;li&gt;when the issuer's server logged the request&lt;/li&gt;
&lt;li&gt;when the transaction was confirmed in a block&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the last one can be checked without trusting the issuer. The first two are the vendor's word. They might be perfectly accurate, but a stranger reading your proof can't confirm them independently, and independent confirmation was the whole point.&lt;/p&gt;

&lt;p&gt;This is why I made ProofLedger's verify path public. &lt;code&gt;GET /api/v1/verify?hash=&amp;lt;sha256&amp;gt;&lt;/code&gt; needs no auth, so an auditor or the other side of a dispute can run it themselves. The &lt;code&gt;verify-proof&lt;/code&gt; PyPI package does the same check locally and offline. And the owner-only status endpoint, &lt;code&gt;GET /api/v1/proof/:id&lt;/code&gt;, hands back the actual &lt;code&gt;chain_tx&lt;/code&gt; and &lt;code&gt;bitcoin_txid&lt;/code&gt;. That way the time can always be traced to a transaction, not just to a field in my database.&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;build-report.txt | &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;h2&gt;
  
  
  A test you can run today
&lt;/h2&gt;

&lt;p&gt;Pick one file you've already timestamped, with any tool or vendor, and work through it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the transaction.&lt;/strong&gt; Can you get from the proof to a specific transaction ID on a specific chain? If the proof only shows a time and a "verified" badge, stop here. What you have is the issuer's claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the time off the block yourself.&lt;/strong&gt; Open that transaction in a block explorer you pick, not one the vendor links you to. Does the block time match what the proof shows you? If it doesn't, figure out which of the times above you were actually looking at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask which bound you need.&lt;/strong&gt; Write one sentence about what this proof is meant to show. If the sentence contains "after," "since," or "fresh," an anchor on its own doesn't cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for a lower bound.&lt;/strong&gt; Open the file. Is there anything in it that couldn't have been known before a specific moment? If not, add one next time, inside the bytes, before hashing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your own gap.&lt;/strong&gt; From your CI logs, look at the time between the artifact being produced and the hash being submitted. That gap is slack in your upper bound that you added yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you get through all five, you'll know what your timestamp proves and where its limits are.&lt;/p&gt;

&lt;p&gt;I build and run ProofLedger myself, and the docs for the three endpoints above are at proofledger.io/api.html.&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>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>If someone asks you to prove it, can you produce the exact bytes you timestamped?</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/if-someone-asks-you-to-prove-it-can-you-produce-the-exact-bytes-you-timestamped-4g19</link>
      <guid>https://dev.to/craig_solomon/if-someone-asks-you-to-prove-it-can-you-produce-the-exact-bytes-you-timestamped-4g19</guid>
      <description>&lt;p&gt;Here's the question worth asking about any release you've hashed, signed or timestamped: if someone challenged it, could you hand them the exact bytes that went into the hash?&lt;/p&gt;

&lt;p&gt;I don't mean "the same release" or "the same source at the same commit". I mean the same bytes. A hash doesn't care what the file means. It only checks bytes. And a proof that some hash existed at some point is worth nothing if you can't produce a file that hashes to it.&lt;/p&gt;

&lt;p&gt;So it's worth working through properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious answer
&lt;/h2&gt;

&lt;p&gt;The obvious answer goes like this. My build is deterministic enough. Same commit, same toolchain, same output. If I ever need the artifact again I'll check out the tag and rebuild.&lt;/p&gt;

&lt;p&gt;Let's test that with the most boring packaging step there is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# build, pack, hash&lt;/span&gt;
make dist
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; release.tar.gz dist/
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;release.tar.gz

&lt;span class="c"&gt;# later: same commit, rebuild, pack, hash&lt;/span&gt;
make clean &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make dist
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; release.tar.gz dist/
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;release.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the two hashes. On a plain GNU tar setup, expect them to be different, even though nothing in your code changed.&lt;/p&gt;

&lt;p&gt;Why? The tarball isn't only your files. It's your files plus metadata about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the extra bytes come from
&lt;/h2&gt;

&lt;p&gt;The tar format stores more than file contents. Every entry gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the file's modification time, which your rebuild just reset&lt;/li&gt;
&lt;li&gt;owner and group, by name and by numeric id, which depend on who ran the build&lt;/li&gt;
&lt;li&gt;permission bits, which depend on the umask of whoever ran it&lt;/li&gt;
&lt;li&gt;an order, which is whatever order the filesystem handed the entries back in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then gzip wraps the whole thing, and the gzip header has its own slot for a timestamp and an original filename.&lt;/p&gt;

&lt;p&gt;None of that is your code, and all of it is in the bytes you hashed. So "I'll just rebuild it" fails, and not because anything is wrong with your project. The thing you timestamped was the archive, and the archive recorded the moment it was built.&lt;/p&gt;

&lt;p&gt;This is the step I think matters most. The proof can be perfectly valid. The chain record is fine, the hash is fine, the verification passes for anyone holding the original file. And you still can't use it, because the original file is gone and you can't regenerate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: make the packaging deterministic
&lt;/h2&gt;

&lt;p&gt;You can pin every one of those metadata fields. With GNU tar and gzip:&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;export &lt;/span&gt;&lt;span class="nv"&gt;SOURCE_DATE_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%ct&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--mtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOURCE_DATE_EPOCH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--numeric-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'u+rwX,go+rX,go-w'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-cf&lt;/span&gt; - dist/ &lt;span class="se"&gt;\&lt;/span&gt;
 | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; release.tar.gz

&lt;span class="nb"&gt;sha256sum &lt;/span&gt;release.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what each flag pins:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--sort=name&lt;/code&gt; fixes entry order so the filesystem doesn't get a say. &lt;code&gt;--mtime&lt;/code&gt; stamps every entry with the commit time instead of the build time. &lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt; is the reproducible-builds convention for this, and some tools read it on their own. &lt;code&gt;--owner&lt;/code&gt;, &lt;code&gt;--group&lt;/code&gt; and &lt;code&gt;--numeric-owner&lt;/code&gt; remove the "who built it" leak. &lt;code&gt;--mode&lt;/code&gt; normalizes permissions so umask stops mattering. &lt;code&gt;gzip -n&lt;/code&gt; drops the name and timestamp from the gzip header.&lt;/p&gt;

&lt;p&gt;Now try the rebuild test again. If the hashes still differ, the drift is coming from inside &lt;code&gt;dist/&lt;/code&gt;. Maybe a generated file embeds a build time, or a bundler orders things differently between runs. That's a real reproducibility bug and you want to know about it anyway.&lt;/p&gt;

&lt;p&gt;Honestly though, this only covers the packaging step. Every tool upstream of &lt;code&gt;tar&lt;/code&gt; gets a vote too. Getting a whole pipeline to reproduce byte for byte is real work, and you might not finish it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: hash the contents instead of the container
&lt;/h2&gt;

&lt;p&gt;Another option is to stop timestamping the archive at all. Timestamp a manifest of what's inside it:&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;cd &lt;/span&gt;dist
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-print0&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 | &lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ../MANIFEST.sha256
&lt;span class="nb"&gt;cd&lt;/span&gt; ..
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;MANIFEST.sha256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The manifest hash is the one you anchor. Now mtimes, owners and archive format stop mattering. You can repackage as zip next year and the manifest still checks out, as long as the file contents match.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LC_ALL=C&lt;/code&gt; matters there. Without it, sort order follows your locale, and a teammate with a different locale gets a different manifest from identical files. Yeah. It's that kind of problem the whole way down.&lt;/p&gt;

&lt;p&gt;What this costs you: the manifest only covers what you put in it. Permissions aren't in it, empty directories aren't in it, and symlinks get followed or skipped depending on how you wrote the &lt;code&gt;find&lt;/code&gt;. If any of that matters for your artifact, it has to go in the manifest explicitly, or you've timestamped less than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix three: just keep the bytes
&lt;/h2&gt;

&lt;p&gt;The least clever option is often the right one. Whatever you hash, store that exact file somewhere it won't get rebuilt over. Store the archive itself, not a recipe for making it.&lt;/p&gt;

&lt;p&gt;Reproducibility is a nice property. But "I can probably regenerate it" is a weaker position than "here it is".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands for the tool I run
&lt;/h2&gt;

&lt;p&gt;I built ProofLedger, and it's fully on the wrong side of this problem on purpose. It hashes the bytes with SHA-256, anchors the hash to Polygon and Bitcoin, and returns a proof. There's no file-type pipeline. It doesn't know a tarball from a JPEG and doesn't try to. The file stays on your machine and only the hash leaves.&lt;/p&gt;

&lt;p&gt;I took that tradeoff deliberately, and it means none of the above is something it can do for you. If you anchor a tarball with a build timestamp baked into its header, you've anchored that exact tarball, and the proof will verify cleanly against that file and nothing else. Deciding which bytes are the canonical ones has to happen before anything gets hashed, and it's your job.&lt;/p&gt;

&lt;p&gt;The verification side is where it's simple. Anyone holding the file can hash it and look it up:&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;&lt;span class="nb"&gt;sha256sum &lt;/span&gt;release.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="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="k"&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 public and needs no auth. There's also a &lt;code&gt;verify-proof&lt;/code&gt; package on PyPI that does the check locally and offline. Both of them answer one narrow question: does this hash have a record? Neither one can tell you the file you're holding is the right one. That part is up to whoever produced it.&lt;/p&gt;

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

&lt;p&gt;Pick an artifact you'd actually need to defend later. Last week's release tarball, a dataset snapshot, an exported log bundle, whatever. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write down its SHA-256.&lt;/li&gt;
&lt;li&gt;Delete your local copy. Seriously, move it out of reach.&lt;/li&gt;
&lt;li&gt;Using only what's in your repo and your CI config, produce it again.&lt;/li&gt;
&lt;li&gt;Hash the result and compare.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it matches, good, your hash points at something you can actually show someone. If it doesn't, find out where your stored copy lives, or whether one exists anywhere at all. And it's better to answer that now than when someone asks.&lt;/p&gt;

&lt;p&gt;ProofLedger lives at proofledger.io if you want to see the other side of it.&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>security</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A hash-chained audit log only proves something once the head leaves your database</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Sun, 11 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/a-hash-chained-audit-log-only-proves-something-once-the-head-leaves-your-database-4kac</link>
      <guid>https://dev.to/craig_solomon/a-hash-chained-audit-log-only-proves-something-once-the-head-leaves-your-database-4kac</guid>
      <description>&lt;p&gt;Here's the question worth asking about your audit log: if someone with write access to your database rewrote the whole thing tonight, what would catch it?&lt;/p&gt;

&lt;p&gt;If the log is hash-chained and the chain lives only in that database, the honest answer is nothing. That's not obvious at first, so let's work through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain itself
&lt;/h2&gt;

&lt;p&gt;The standard move is to make each record commit to the one before it. Every entry stores the previous entry's hash, and its own hash covers both that pointer and its contents. Change any record and every hash after it stops matching.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="n"&gt;GENESIS&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="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;genesis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;record_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&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;,&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;:&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;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;prev_hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&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;span class="nf"&gt;hexdigest&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;append&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="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&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;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;log&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;GENESIS&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;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;record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prev&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="nf"&gt;record_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&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;verify_chain&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="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GENESIS&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;entry&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="n"&gt;log&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;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prev&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;prev&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;entry&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="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;record_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;record&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;i&lt;/span&gt; &lt;span class="c1"&gt;# first broken entry
&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&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;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# chain is consistent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick note on the &lt;code&gt;json.dumps&lt;/code&gt; line, because it matters more than it looks. &lt;code&gt;sort_keys=True&lt;/code&gt; and the fixed separators give you a canonical serialization. Without them, the same logical record can serialize to different bytes depending on dict ordering or whoever re-serializes it later, and your verifier ends up reporting tampering that never happened. A hash commits to bytes, not meaning, so pin the bytes down.&lt;/p&gt;

&lt;p&gt;Okay. So now if someone edits record 3 in place and leaves everything else alone, &lt;code&gt;verify_chain&lt;/code&gt; returns 3. Tamper-evident. Done?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls apart
&lt;/h2&gt;

&lt;p&gt;The person who can edit record 3 can also run &lt;code&gt;append&lt;/code&gt; in a loop. Here's the whole attack:&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;rewrite&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="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
 &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;record&lt;/span&gt;&lt;span class="sh"&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;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
 &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_record&lt;/span&gt;
 &lt;span class="n"&gt;rebuilt&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&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;rebuilt&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rebuilt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed the result to &lt;code&gt;verify_chain&lt;/code&gt; and it returns &lt;code&gt;None&lt;/code&gt;. Perfectly consistent. Every &lt;code&gt;prev&lt;/code&gt; points at the right hash, every hash recomputes. The chain proves the log agrees with itself, and a freshly forged log agrees with itself just as well as the original did.&lt;/p&gt;

&lt;p&gt;That's the step that's easy to skip. A hash chain proves internal consistency. It doesn't prove the log you're looking at is the log that existed yesterday. For that you need a value from yesterday that the rewriter couldn't touch.&lt;/p&gt;

&lt;p&gt;Being an admin doesn't save you either. The threat model for an audit log of admin actions includes the admin. If the integrity check runs entirely on infrastructure the admin controls, it's checking the admin's homework with the admin's answer key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is small: publish the head
&lt;/h2&gt;

&lt;p&gt;Look at what the chain gives you for free. The hash of the latest entry, the head, commits to every entry before it. Change anything earlier and the head changes. So you don't have to protect every record. You protect one value.&lt;/p&gt;

&lt;p&gt;Take the head hash at some point and put it somewhere outside your control, somewhere the rewriter can't edit after the fact. From then on, anyone holding that published value can take your log, recompute the chain up to that entry, and compare. Match means the prefix is the same one that existed when the head was published. Mismatch means something before that point changed, and the forger can't rebuild their way past it, because the published value doesn't move.&lt;/p&gt;

&lt;p&gt;What does "somewhere outside your control" mean in practice? It means a place where you can't change the value after the fact and a third party can check it without asking you. Emailing it to yourself doesn't count. A row in a different table in the same database definitely doesn't count.&lt;/p&gt;

&lt;p&gt;This is the thing I built ProofLedger to do. You submit a SHA-256 hash and it gets anchored to Polygon and Bitcoin. Only the hash goes on chain. The log itself never leaves your systems, which matters when the log is full of user IDs and admin actions you'd never publish.&lt;/p&gt;

&lt;p&gt;Anchoring the head looks roughly like this: compute it, then POST it to &lt;code&gt;/api/v1/proof&lt;/code&gt; with your &lt;code&gt;sk_&lt;/code&gt; key as a Bearer token. The part I care about more is the other direction, checking it:&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;HEAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import mylog; print(mylog.load()[-1]['hash'])"&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;$HEAD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That verify endpoint is public, no auth, rate limited to 120 requests per hour per IP. I made that call on purpose. If an auditor has to log into the issuer's account to check the issuer's claim, you're back to the answer-key problem. The &lt;code&gt;verify-proof&lt;/code&gt; package on PyPI does the same check offline and locally, so the verifier doesn't even have to trust my server being up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What anchoring doesn't buy you
&lt;/h2&gt;

&lt;p&gt;I'd rather you know the limits up front than find them later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The window after the last anchor is unprotected.&lt;/strong&gt; Anything appended since the most recent published head can be rewritten or dropped and nothing external will notice. How often you anchor sets how big that window is. That's a decision you make for your threat model, not something anchoring decides for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Truncation looks a lot like "nothing happened."&lt;/strong&gt; If someone deletes the tail after your last anchor, the remaining log still verifies against the anchored head. To catch that you need something that says how long the log should be, like the next anchor or an independent record that entries were written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It proves "unchanged since," not "true when written."&lt;/strong&gt; If the code writing the audit entry logs the wrong actor, the chain will faithfully preserve the wrong actor. Integrity isn't accuracy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your serialization is part of the protocol now.&lt;/strong&gt; Change the &lt;code&gt;json.dumps&lt;/code&gt; arguments in a refactor and every historical head stops reproducing. Version it, test it, treat it like a wire format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the anchor itself isn't magic.&lt;/strong&gt; Anyone can timestamp a hash on Bitcoin for free with OpenTimestamps, and that proof is just as cryptographically valid. What you're picking between is the tooling around the proof, not a stronger proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run on your own setup today
&lt;/h2&gt;

&lt;p&gt;Take your audit log, or any hash-chained thing you own: event store, migration ledger, release manifest. Then do this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick an entry from the middle, change one field, and rebuild the chain from that point forward, the way &lt;code&gt;rewrite&lt;/code&gt; does above.&lt;/li&gt;
&lt;li&gt;Run whatever integrity check you have against the rebuilt log.&lt;/li&gt;
&lt;li&gt;If it passes, ask yourself one thing: what value, stored outside the infrastructure you just used to forge it, could I compare this head against?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can't name one, what you've got is a checksum. Useful for catching bugs, not for catching people. If you can name one, write down who besides you could fetch it and run the comparison without asking you first. That list is the real answer to who can trust your log.&lt;/p&gt;

&lt;p&gt;ProofLedger is where I run the anchoring side of this, at proofledger.io. API docs are at proofledger.io/api.html if you want to see the three endpoints.&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>security</category>
      <category>python</category>
      <category>cryptography</category>
      <category>devops</category>
    </item>
    <item>
      <title>What a failed hash check actually tells you about your file</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Fri, 09 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/craig_solomon/what-a-failed-hash-check-actually-tells-you-about-your-file-1ch2</link>
      <guid>https://dev.to/craig_solomon/what-a-failed-hash-check-actually-tells-you-about-your-file-1ch2</guid>
      <description>&lt;p&gt;Your timestamp check comes back "not found." What did you actually learn?&lt;/p&gt;

&lt;p&gt;It's tempting to read that as "the file was changed." Sometimes that's true. But the check didn't tell you so. What it tells you is narrower than that, and the space between the two is exactly where you lose an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually compares
&lt;/h2&gt;

&lt;p&gt;A timestamp proof for a file is really a proof about a hash. Here's how the thing I run, ProofLedger, works. It hashes the bytes with SHA-256, anchors that hash on Polygon and Bitcoin, and hands back a proof. Verifying means hashing again and asking whether that hash was anchored.&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;&lt;span class="nb"&gt;sha256sum &lt;/span&gt;release.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;It's public and needs no auth. Either there's an anchored proof for that exact hash or there isn't.&lt;/p&gt;

&lt;p&gt;That's the only question it can answer: were &lt;em&gt;these exact bytes&lt;/em&gt; anchored? It can't tell you whether this is the same file or whether the content changed. It only knows bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the obvious answer breaks
&lt;/h2&gt;

&lt;p&gt;So a miss means tampering, right? The hash changed, so somebody changed the file.&lt;/p&gt;

&lt;p&gt;No. SHA-256 has no idea what "content" means. Flip any bit anywhere and you get an unrelated hash. Plenty of ordinary tooling rewrites bytes when nobody meant to change anything.&lt;/p&gt;

&lt;p&gt;Take gzip. It writes the original filename and a modification time into its header. Compress the identical tarball again later and you get a different &lt;code&gt;.gz&lt;/code&gt; with a different hash. &lt;code&gt;gzip -n&lt;/code&gt; leaves those fields out.&lt;/p&gt;

&lt;p&gt;Tar is the same story. It records mtime and ownership for every entry, and it stores files in whatever order it walked the directory. Check out the same source tree fresh and you can get a different archive. GNU tar has &lt;code&gt;--sort=name&lt;/code&gt; and &lt;code&gt;--mtime&lt;/code&gt; to pin those down.&lt;/p&gt;

&lt;p&gt;Git does it too. With &lt;code&gt;core.autocrlf&lt;/code&gt; on, it rewrites line endings at checkout. The text file you anchored on one machine and the one somebody checks out on another can differ on every single line and still look identical in an editor.&lt;/p&gt;

&lt;p&gt;Zip stores a timestamp per entry as well.&lt;/p&gt;

&lt;p&gt;None of that is tampering, and every bit of it reads as a miss.&lt;/p&gt;

&lt;p&gt;Now look at the other direction. A hit is a strong statement. If the hash matches, those exact bytes existed by the time of the anchor. Making a different file that produces the same SHA-256 is precisely what the function is designed to make infeasible.&lt;/p&gt;

&lt;p&gt;So the check is lopsided. A hit tells you a lot. A miss, by itself, tells you almost nothing about &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision this forced on the engine
&lt;/h2&gt;

&lt;p&gt;ProofLedger has one engine and no file-type pipeline. It doesn't open your archive or normalize line endings, and it doesn't care what's inside. It hashes bytes.&lt;/p&gt;

&lt;p&gt;That has a real cost. It won't save you from your own gzip header. A smarter version is easy to picture: canonicalize the archive first, strip the timestamps, sort the entries, then hash. Plenty friendlier on a miss.&lt;/p&gt;

&lt;p&gt;But then the proof would be about something other than the bytes you're holding. And anyone checking it would need my canonicalization code to reproduce the hash. That breaks the part I care about, which is that anybody with &lt;code&gt;sha256sum&lt;/code&gt; and a public lookup gets the same answer I do without asking me. A timestamp is only worth something if the person relying on it can check it themselves.&lt;/p&gt;

&lt;p&gt;So I took the trade. The engine stays dumb about content, and whoever produces the bytes carries the burden of reproducibility. That's also why the file never has to leave your machine. Only the hash goes on chain, so there's nothing for the service to inspect even if it wanted to.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which bytes are the record?
&lt;/h2&gt;

&lt;p&gt;The fix isn't on the verification side. It's a decision you make before you anchor anything: which exact bytes are the record, and are those the bytes you'll keep?&lt;/p&gt;

&lt;p&gt;If you anchor a build artifact and plan to "just rebuild it later" when someone asks, your proof points at a file you no longer have. Unless your build is reproducible right down to the compression header, the rebuild is a different file as far as the hash is concerned. Keep the anchored bytes, the actual ones. Don't keep a recipe for making similar ones.&lt;/p&gt;

&lt;p&gt;And if what you really want is to show that the content didn't change across rebuilds, then determinism has to come first. Get &lt;code&gt;gzip -n&lt;/code&gt;, sorted tar entries and a pinned mtime into the build, confirm two builds match, and anchor after that. Anchoring a nondeterministic artifact gives you an accurate proof about a file you can't produce again.&lt;/p&gt;

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

&lt;p&gt;The public verify endpoint is rate limited to 120 requests per hour per IP. That's fine for spot checks, not for hammering from a big pipeline. For CI there's a GitHub Action, and the &lt;code&gt;verify-proof&lt;/code&gt; package on PyPI does the same check locally and offline.&lt;/p&gt;

&lt;p&gt;None of that changes the core point. A miss means these bytes weren't anchored, full stop. Figuring out why is on you, and the tools above won't do it for you. Neither will any other hash-based timestamping I know how to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run this against your own pipeline today
&lt;/h2&gt;

&lt;p&gt;Pick an artifact your build produces that you think of as "the same every time." Build it twice from the same commit, into two directories, and hash both:&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;build-a/release.tar.gz build-b/release.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the hashes differ, any timestamp or signature you put on the first one doesn't cover the second.&lt;/p&gt;

&lt;p&gt;Then peel off a layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zcat build-a/release.tar.gz | &lt;span class="nb"&gt;sha256sum
&lt;/span&gt;zcat build-b/release.tar.gz | &lt;span class="nb"&gt;sha256sum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those match, the gzip header was the difference. If they don't, list both tarballs with &lt;code&gt;tar -tvf&lt;/code&gt; and diff the output. Mtimes and ordering usually show up right there. Either way, you'll know whether you're anchoring a record or a moving target.&lt;/p&gt;

&lt;p&gt;ProofLedger is the hash-anchoring service I built and run myself, at proofledger.io.&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>security</category>
      <category>devops</category>
      <category>cicd</category>
      <category>blockchain</category>
    </item>
    <item>
      <title>A hash is one-way until someone can guess the file</title>
      <dc:creator>Craig Solomon</dc:creator>
      <pubDate>Wed, 07 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/craig_solomon/a-hash-is-one-way-until-someone-can-guess-the-file-6hd</link>
      <guid>https://dev.to/craig_solomon/a-hash-is-one-way-until-someone-can-guess-the-file-6hd</guid>
      <description>&lt;p&gt;Here's a question worth asking about anything you've timestamped, or anything you've published a hash of. If someone could guess what's in the file, could they confirm the guess from the hash alone?&lt;/p&gt;

&lt;p&gt;For some files, yes. And "it's SHA-256, it's one-way" doesn't save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comfortable answer
&lt;/h2&gt;

&lt;p&gt;When you timestamp a file with ProofLedger, the file never leaves your machine. You hash the bytes locally and send the hash. The hash gets anchored to Polygon and Bitcoin. That's the whole engine. It doesn't look inside the file, and it doesn't care what kind of file it is.&lt;/p&gt;

&lt;p&gt;So the comfortable reasoning goes like this. The file stays private. Only a digest goes public. You can't reverse SHA-256. Done.&lt;/p&gt;

&lt;p&gt;That reasoning is right about the function. It's wrong about the situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;One-way means you can't go from the hash back to the input. It says nothing about going forward. Anyone can hash a candidate and compare. If the set of plausible inputs is small, nobody has to reverse anything. They just try them all.&lt;/p&gt;

&lt;p&gt;And the lookup is public on purpose. Anyone can hit the verify endpoint without an account:&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;decision.txt
curl &lt;span class="s2"&gt;"https://proofledger.io/api/v1/verify?hash=&amp;lt;sha256&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what it's for. Opposing counsel or an auditor should be able to check a proof without asking me, and without asking you. If the only way to verify a timestamp is to ask whoever issued it, it isn't worth much.&lt;/p&gt;

&lt;p&gt;But "anyone can check whether this hash has a proof" also means "anyone with a guess can check whether their guess has a proof." Same endpoint, same property, read from the other side.&lt;/p&gt;

&lt;p&gt;Here's the concrete version. Say the file you timestamped is just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;board vote: approve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone who knows a vote happened doesn't need your file. They need a list:&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="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
 &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;board vote: approve&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;board vote: reject&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;board vote: defer&lt;/span&gt;&lt;span class="se"&gt;\n&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;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&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="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;c&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check each digest against the public lookup, and whichever one has a proof is your answer. The lookup is rate limited to 120 requests per hour per IP. That slows down a wide search. A narrow one doesn't even notice it. And the endpoint isn't the only place the hash lives. Once it's anchored it's on chain, and the chains are public.&lt;/p&gt;

&lt;p&gt;There's one thing working in your favor here, and it's worth being precise about. The guesser needs the exact bytes. Trailing newline or not, CRLF or LF, a space after the colon. Every one of those changes the digest. So if your file is freeform and messy, guessing it is hopeless.&lt;/p&gt;

&lt;p&gt;The catch is that structured files aren't messy. A config with one flag in it. A JSON blob where the only field that varies is an amount. A template where the only thing filled in is a name. If the format is known, the bytes are predictable, and all that's left to guess is the part that matters.&lt;/p&gt;

&lt;p&gt;A photo or a compiled build artifact? Nobody's guessing those. So the risk isn't hashes in general. It's small, structured, predictable inputs, and you're the only one who knows which of your files look like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it costs
&lt;/h2&gt;

&lt;p&gt;The fix is to make the input unguessable. Put random bytes in it before you hash. The simplest way is a salt you keep:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;

&lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_hex&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision.txt&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;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="n"&gt;data&lt;/span&gt; &lt;span class="o"&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="n"&gt;digest&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;salt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;salt: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&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;digest:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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;&lt;code&gt;POST /api/v1/proof&lt;/code&gt; takes a hash, not a file, so whatever digest you compute is what gets anchored. The engine doesn't know a salt went in, and it doesn't need to.&lt;/p&gt;

&lt;p&gt;It doesn't salt anything for you either, and I'd rather keep it that way. If the service generated and held the salt, that'd be a secret the issuer keeps. Then checking the proof means asking the issuer, which is the exact thing the public verify path exists to avoid.&lt;/p&gt;

&lt;p&gt;Now the part that matters. Salting isn't free, and the costs are the reason this is a decision and not a default.&lt;/p&gt;

&lt;p&gt;First, the verifier now needs the file and the salt. Hash the file on its own, look it up, and you'll find nothing. You haven't removed the need to share a secret. You've moved it. The file stays private until you choose to hand over the salt alongside it.&lt;/p&gt;

&lt;p&gt;Second, lose the salt and the proof is dead. The anchor is still sitting on two chains, perfectly valid, attached to a digest nobody can reproduce. So the salt needs to be stored somewhere at least as durable as the file. If it only lives in a terminal scrollback, it's gone.&lt;/p&gt;

&lt;p&gt;Third, the construction has to be written down. Salt first or file first? Hex string or raw bytes? Any separator? Whatever tool checks the proof later, whether that's the public endpoint, the verify-proof package running offline, or the GitHub Action in CI, it's checking the digest you submitted. The person verifying has to rebuild that digest your way first. If your way only exists in your head, they can't.&lt;/p&gt;

&lt;p&gt;There's one variant that dodges the first cost. Put the random value inside the file itself, as a comment line or an extra field. Then the file alone reproduces the hash, and the plain lookup works again for anyone you've given the file to. The trade is that the file is no longer quite the artifact you'd naturally have. Sometimes that's fine. If the point is "this exact export from this exact system existed," it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Pick the most sensitive file you've hashed somewhere public. A timestamp, a checksum in a release note, a digest in a log.&lt;/p&gt;

&lt;p&gt;Then actually write the candidates list. Don't just think about whether you could. Open an editor and type out every version of that file someone with outside knowledge would consider plausible, byte for byte, newlines included. Hash each one.&lt;/p&gt;

&lt;p&gt;If you can finish the list, that hash was never protecting the contents. It's an answer waiting for someone to ask the right question. From there you either salt the next one and accept the costs above, or you decide the contents were never really the secret. The timing was. Both are legitimate. Just pick one on purpose.&lt;/p&gt;

&lt;p&gt;I built and run ProofLedger, and the verify endpoint and the other two are documented at proofledger.io/api.html if you want to see exactly what's public.&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>security</category>
      <category>cryptography</category>
      <category>privacy</category>
      <category>api</category>
    </item>
    <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>
  </channel>
</rss>
