<?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: alphanumericentity</title>
    <description>The latest articles on DEV Community by alphanumericentity (@alphanumericentity).</description>
    <link>https://dev.to/alphanumericentity</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%2F4117272%2Ff1fcd21c-2d2f-4c99-9c20-2bfc09d4b12e.png</url>
      <title>DEV Community: alphanumericentity</title>
      <link>https://dev.to/alphanumericentity</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alphanumericentity"/>
    <language>en</language>
    <item>
      <title>[REDACTED] doesn't mean deleted</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Sat, 14 Nov 2026 03:10:02 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/redacted-doesnt-mean-deleted-3bk6</link>
      <guid>https://dev.to/alphanumericentity/redacted-doesnt-mean-deleted-3bk6</guid>
      <description>&lt;p&gt;Here's a PDF. Open it, and one line reads &lt;code&gt;Name and SSN: [REDACTED]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Copy the text out of it, and you get &lt;code&gt;[REDACTED]&lt;/code&gt;. Search the whole file for the SSN, and you find nothing. Open it in &lt;code&gt;qpdf&lt;/code&gt;, a PDF debugging tool that unpacks the document and prints everything in it, and you still find nothing.&lt;/p&gt;

&lt;p&gt;Now run one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;head -c 823 released.pdf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; recovered.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the first 823 bytes of a 1,158-byte file and throws the rest away. Open &lt;code&gt;recovered.pdf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name and SSN: Marigold-Vance-447-26-8891
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't a corrupted file or a trick. It's a perfectly valid PDF, and it's the document exactly as it was before the edit.&lt;/p&gt;

&lt;p&gt;(The SSN is made up. I built every file in this post, so nobody's real data is involved.)&lt;/p&gt;

&lt;p&gt;The part that stung: I ran this file through the redaction checker on my own site, with the SSN typed into its search box, and it said &lt;strong&gt;"Nothing recoverable found."&lt;/strong&gt; I've fixed that, and the fix turned out to be the most useful thing in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFs can keep their own history
&lt;/h2&gt;

&lt;p&gt;PDF has a feature most people never hear about. When an editor saves, it doesn't have to rewrite the file. It can add just the changed parts onto the end, plus a note saying "these replace the old ones."&lt;/p&gt;

&lt;p&gt;If you're a developer, think git. Every save like that is a new commit on top. Your PDF viewer only ever checks out the latest commit, so you see &lt;code&gt;[REDACTED]&lt;/code&gt;. But the old commit is still sitting in the history, and in a PDF the history is simply the front of the file. Cut the file where the first save ended, and you've checked out the original.&lt;/p&gt;

&lt;p&gt;That's why every check missed it. Copying the text, searching, and the debugging tool all ask what the document says &lt;em&gt;now&lt;/em&gt;, and they all answer correctly: it says &lt;code&gt;[REDACTED]&lt;/code&gt;. The old version isn't part of the document anymore. It's still part of the file.&lt;/p&gt;

&lt;p&gt;The plain search failed for a more boring reason. PDF page content is usually compressed, with the same algorithm zip uses, so the SSN is in there, just not as readable text.&lt;/p&gt;

&lt;p&gt;Nothing warns you when an editor saves this way. The file looks, opens and prints like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways to "remove" an SSN
&lt;/h2&gt;

&lt;p&gt;I built four PDFs that each claim the SSN is gone, and ran three checks on each. ✅ means the check caught the problem. ❌ means it missed it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How the SSN was "removed"&lt;/th&gt;
&lt;th&gt;Copy the text&lt;/th&gt;
&lt;th&gt;Search the file&lt;/th&gt;
&lt;th&gt;Inspect with qpdf&lt;/th&gt;
&lt;th&gt;Still recoverable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Black box drawn over it&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted, but left in the metadata&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edited and saved by appending&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, with normal compression&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two are the classic mistakes, and &lt;a href="https://pdfairlock.com/blog/pdf-redaction-verification" rel="noopener noreferrer"&gt;I've written about them before&lt;/a&gt;. A black box is just a shape drawn on top of text that's still there. Metadata is a properties panel that apps fill in on their own and people rarely check.&lt;/p&gt;

&lt;p&gt;The last row is what this post is about. Every check misses it, and the secret is still one command away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching it without knowing what to look for
&lt;/h2&gt;

&lt;p&gt;Searching only works if you know what you're searching for. Often you don't, because you're checking a file somebody else redacted.&lt;/p&gt;

&lt;p&gt;The good news is that the file gives itself away. Every save ends with a short footer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="n"&gt;startxref&lt;/span&gt;
&lt;span class="mi"&gt;608&lt;/span&gt;
&lt;span class="c1"&gt;%%EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF saved once has one footer. A PDF that was edited and saved by appending has one per save. So the check is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find every footer.&lt;/li&gt;
&lt;li&gt;Cut the file at each one except the last. Each cut is an older version.&lt;/li&gt;
&lt;li&gt;Pull the text out of each older version.&lt;/li&gt;
&lt;li&gt;Report any text that's in an older version but missing from the current one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No search term needed. On the file above, that turns up the full SSN line on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I almost shipped
&lt;/h2&gt;

&lt;p&gt;My first version was simpler: more than one &lt;code&gt;%%EOF&lt;/code&gt; means edit history, so warn.&lt;/p&gt;

&lt;p&gt;Then I tested it on a "linearized" PDF, the kind saved for fast loading on the web, which I made from the clean, saved-once file. It had two footers and no history at all. That detector would have flagged perfectly normal PDFs as edited.&lt;/p&gt;

&lt;p&gt;The difference is that in the linearized file, the extra footer points at position 0. A real save can never point there, because position 0 is where the file's &lt;code&gt;%PDF&lt;/code&gt; header lives. So the checker ignores footers that point at 0. It also ignores the first footer of any file that says it's linearized, in case some tool writes a different number there.&lt;/p&gt;

&lt;p&gt;And it compares text rather than counting footers. So even if it misreads a footer, it can only report a leak when an older version really does hold text you can no longer see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grep -c %%EOF yourfile.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1&lt;/code&gt; means the file was written in one go. More than 1 means it has edit history, or it's a fast-loading web PDF. To find out which, and to see exactly what any older version contains, drop it into the &lt;a href="https://pdfairlock.com/pdf-redaction-checker" rel="noopener noreferrer"&gt;redaction checker&lt;/a&gt;. It runs in your browser, so the file never leaves your machine.&lt;/p&gt;

&lt;p&gt;The checker now does everything above. It finds older versions, pulls the text from each, lists anything the current pages don't show, and searches whatever you type in every version, not just the latest. If older versions exist but their text matches, it still tells you, because a removed photo or signature wouldn't show up when comparing text.&lt;/p&gt;

&lt;p&gt;PDFAirlock's own redaction tool never had this problem, because it doesn't edit your file. It builds a brand-new one, with the redacted pages rebuilt as images and the metadata stripped, so there's no history for anything to hide in. It's all open source (AGPL), and the detector is &lt;a href="https://github.com/alphanumericentity/pdfairlock" rel="noopener noreferrer"&gt;in the repo&lt;/a&gt; if you want to borrow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't tested
&lt;/h2&gt;

&lt;p&gt;I built these files myself, so this shows &lt;em&gt;how&lt;/em&gt; the leak works, not &lt;em&gt;how often&lt;/em&gt; it happens. I haven't scanned anyone's real documents, and I'd be wary of anyone quoting a percentage without saying where it came from.&lt;/p&gt;

&lt;p&gt;Other leaks I know about but didn't build here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scanned PDFs with a hidden, searchable text layer behind the image&lt;/li&gt;
&lt;li&gt;Thumbnail previews saved from before the redaction&lt;/li&gt;
&lt;li&gt;Accessibility tags that keep a second copy of the text for screen readers&lt;/li&gt;
&lt;li&gt;Embedded fonts that still list which letters were used&lt;/li&gt;
&lt;li&gt;Leftover compressed pieces of a file that no version points to. Every check above misses those, my checker included.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So here's my question
&lt;/h2&gt;

&lt;p&gt;Text, I can now catch. Pictures, I can't.&lt;/p&gt;

&lt;p&gt;If someone deletes a face or a signature and the editor saves by appending, the checker can tell you an older version exists. It can't tell you that version holds a face someone meant to destroy. Comparing the pixels of each version will show that &lt;em&gt;something&lt;/em&gt; changed, but to a pixel diff, a moved logo and a deleted face look exactly the same.&lt;/p&gt;

&lt;p&gt;How would you tell, automatically, that an image in an older version was meant to be removed? I don't have an answer I trust, and I'd really like one.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Green tests, dead tripwire</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Tue, 03 Nov 2026 05:48:17 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/green-tests-dead-tripwire-30bc</link>
      <guid>https://dev.to/alphanumericentity/green-tests-dead-tripwire-30bc</guid>
      <description>&lt;p&gt;Here is a failure I had not thought about until someone asked me the right question.&lt;/p&gt;

&lt;p&gt;You write a &lt;code&gt;PreToolUse&lt;/code&gt; hook. It reads the tool call, decides the command is dangerous, and blocks it. You write unit tests for it, because a guard with no tests is a wish. The tests pass. Months go by and the hook never fires, which you read as good news, because nothing bad has happened.&lt;/p&gt;

&lt;p&gt;Then one day you find out the harness renamed a field in the payload eighteen weeks ago, your hook has matched nothing since, and every one of your tests is still green. They're green because the test builds the payload itself, in the shape you believed the harness sends. The test and the hook agree with each other perfectly. Neither of them has spoken to the harness in months.&lt;/p&gt;

&lt;p&gt;Green tests, dead tripwire. The guard and its tests share a belief, so the tests cannot see the belief go stale.&lt;/p&gt;

&lt;p&gt;This is the same shape as a bug I wrote about last time, where a redaction tool verified its own output using the same wrong geometry it had used to draw with. Different domain, identical structure: a check built from the same assumption as the thing it checks is not a check, it's a second copy of your belief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it hasn't fired" tells you nothing
&lt;/h2&gt;

&lt;p&gt;A guard has two states that look identical from the outside. Working and quiet, because nothing tripped it. Broken and quiet, because it cannot match anything any more.&lt;/p&gt;

&lt;p&gt;Nothing in your workflow distinguishes them. You don't get a weekly report of hooks that ran. The absence of refusals feels like safety, and in the broken case it is precisely the opposite: you've been running unguarded while believing you were covered, which is worse than having no hook at all, because you'd have been more careful without one.&lt;/p&gt;

&lt;p&gt;Every guard that encodes a fact about an external world has this problem. The payload shape of your agent harness. The name of a tool. Whether CI still runs the check your hook assumes runs. Those facts change without telling you, and the guard goes quiet rather than loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed, but not everywhere
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to fail closed. If this is a tool I'm guarding and the field I need isn't there, that's an anomaly, not an allow. Most hooks, mine included, default to allowing anything they can't parse, which is exactly backwards.&lt;/p&gt;

&lt;p&gt;The question I got asked, and did not have a good answer for at first, was whether that means failing closed everywhere, or only for guards on irreversible things.&lt;/p&gt;

&lt;p&gt;I now think irreversibility is the wrong axis. The right one is what happens when the hook declines to decide. Three modes, three correct failures:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Block mode: fail closed.&lt;/strong&gt; The hook is certain from the input alone that the call is wrong. If it can't read the input, refuse. The fallback is a retry or a question, both cheap, and the false block you eat on the day the harness changes is the thing that tells you the shape moved. That one annoying refusal is the monitoring you never set up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warn mode: don't fail closed, and say why.&lt;/strong&gt; A warn hook exists because the rule is a heuristic that will fire on legitimate work, so turning an unparseable payload into a wall contradicts its own design. Emit the warning anyway. And the detail I'd have got wrong: the warning has to say precisely that it couldn't read the input, rather than printing its usual generic text. A warning that can't name the file or the command, but talks as if it can, teaches the model to discount every future warning from that hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-allow mode: already fails closed, for free.&lt;/strong&gt; This is the mode nobody discusses. Hooks that pre-approve read-only commands so they never surface a permission prompt. If one can't parse the input, it simply doesn't allow, and you land on the normal prompt. The fallback is a human, which is exactly where you want to be.&lt;/p&gt;

&lt;p&gt;So the rule I'd write now: fail closed wherever declining costs a prompt. Don't, wherever declining costs a block on legitimate work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixture that lies to you
&lt;/h2&gt;

&lt;p&gt;The other half is contract tests, and there's a trap in them.&lt;/p&gt;

&lt;p&gt;Recording one real payload from an actual session and replaying it against the hook catches the rename before it costs you anything. Writing that fixture by hand does not, because a hand-written fixture is your belief about the payload shape stored in a second file. You now have two copies of the same assumption and a false sense of coverage. If you can't capture a real one, log the raw input on every invocation and go read it occasionally. Dull, works.&lt;/p&gt;

&lt;p&gt;My own setup, honestly: two hooks have real unit tests, one with a table of about thirty adversarial command variants, and both assert the deny path rather than only the happy one. Neither has a recorded fixture. I haven't fixed it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't have
&lt;/h2&gt;

&lt;p&gt;The thing I'd actually want is a canary. A scheduled job that deliberately issues a known-bad call and asserts it gets refused, so a dead guard announces itself instead of waiting to be discovered. That's a cron and an assertion, it isn't hard, and I haven't built it, which I suspect means most people running a dozen hooks haven't either.&lt;/p&gt;

&lt;p&gt;Most of this post came out of the comments on my last one rather than from me. &lt;a class="mentioned-user" href="https://dev.to/build996"&gt;@build996&lt;/a&gt; pushed on which refusals an agent can actually act on, &lt;a class="mentioned-user" href="https://dev.to/max_quimby"&gt;@max_quimby&lt;/a&gt; on hooks quietly becoming an unversioned rulebook nobody remembers the reasons for, &lt;a class="mentioned-user" href="https://dev.to/syntaxwanderer_26"&gt;@syntaxwanderer_26&lt;/a&gt; asked the question that produced the whole fail-closed taxonomy and then supplied the warn-mode detail, and &lt;a class="mentioned-user" href="https://dev.to/doriansotpyrc"&gt;@doriansotpyrc&lt;/a&gt; asked how a rule earns promotion from prose into a hook, which I answered as: once means write the sentence, twice means the sentence isn't carrying it.&lt;/p&gt;

&lt;p&gt;So, the part I'm stuck on. How would you detect a guard that has been dead for a month? A canary is the only honest answer I've got, and it only covers the failures you thought to simulate. If you've found something better, or you've had a tripwire die quietly and only noticed by accident, I'd like to hear it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>discuss</category>
      <category>testing</category>
    </item>
    <item>
      <title>The agent did exactly what I asked. That was the bug.</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Tue, 06 Oct 2026 02:21:18 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/the-agent-did-exactly-what-i-asked-that-was-the-bug-3a4i</link>
      <guid>https://dev.to/alphanumericentity/the-agent-did-exactly-what-i-asked-that-was-the-bug-3a4i</guid>
      <description>&lt;h2&gt;
  
  
  Three times this month my agent executed a flawed request perfectly. Hooks can't catch this one, because the mistake isn't in any single tool call, it's in the plan. Here's what I changed instead.
&lt;/h2&gt;

&lt;p&gt;I wrote a while back about moving rules out of my instructions file and into hooks, because a rule in prose gets followed most of the time and a rule in a tripwire gets followed every time. I still believe that. Someone in the comments pushed on where determinism stops being the right tool, and I didn't have a good answer then.&lt;/p&gt;

&lt;p&gt;I do now, because three times in the past month my agent did precisely what I asked, did it well, and the result was wrong anyway. Not a hallucination, not a bad diff. Correct execution of a flawed request. That class of failure has no tool call to intercept, which makes it the exact thing a hook cannot save you from.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: I asked for a demo GIF and got a security bug
&lt;/h2&gt;

&lt;p&gt;I have a side project that redacts PDFs in the browser. I wanted an animated demo for the landing page, so I asked the agent to drive the app with Playwright and record the whole flow: open a file, drag a box over a name, export, done.&lt;/p&gt;

&lt;p&gt;It built the script. The script worked. I watched the GIF and the black box was sitting next to the name instead of on top of it.&lt;/p&gt;

&lt;p&gt;The redaction code was placing boxes by splitting a text run's total width by its character count, so a box over characters 4 to 9 was a flat fraction of the run. That's correct for a monospaced font and wrong for every other font, and the drift grows with the length of the run. Sometimes it covered the word. Sometimes it covered the word and a bit of the next one. Sometimes it sat just to the right of the thing I was trying to hide.&lt;/p&gt;

&lt;p&gt;Here is the part that stings. The app already verified its own output. After exporting, it re-opens the PDF it just produced, extracts the text layer and checks that the redacted string is gone. That check passed every single time, and it was right to pass: the text really was destroyed. It just wasn't the text under the box the user drew. My verifier and my renderer were computing position the same wrong way, so the verifier could not see the error even in principle.&lt;/p&gt;

&lt;p&gt;I asked for a marketing asset. What found the bug was that a GIF is looked at by a human with eyes, and every assertion I had written shared an assumption with the code it was testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: a perfect entry in the wrong section
&lt;/h2&gt;

&lt;p&gt;Different day. I wanted my project listed in one of the big awesome-privacy lists, so I asked the agent to add it to the Office section and open the PR.&lt;/p&gt;

&lt;p&gt;It did the work properly. Read the contributing guide, matched the entry format down to the trailing source-link convention the other entries use, filled in the seven-point checklist, picked an insertion point that wouldn't conflict with other open PRs, wrote a disclosure paragraph. Genuinely good work.&lt;/p&gt;

&lt;p&gt;I caught it at the last second, with the commit dialog open: that section is titled "Avoid Microsoft Office and Google Docs, instead use", and everything in it is a suite, an editor or a spreadsheet. A PDF utility in there is a category error. Any maintainer would have closed it, and fairly.&lt;/p&gt;

&lt;p&gt;The agent never had a reason to question the section. I named it. It optimized for "is this entry well-formed and well-argued", which it was, and never for "is Office the right section", because I'd already answered that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three: a confident click path to a setting that lives somewhere else
&lt;/h2&gt;

&lt;p&gt;Smallest one, same shape. I asked where a particular GitHub repo setting lives. I got "Settings, then General", stated plainly, with the surrounding steps.&lt;/p&gt;

&lt;p&gt;It isn't there. That field is behind a gear icon next to the About panel on the repo's front page, which is not Settings at all. The answer was assembled from what a settings page plausibly contains. When I made it go read the documentation page first, it came back with the right path in one shot, and with the character limits and naming rules I hadn't thought to ask about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these have in common
&lt;/h2&gt;

&lt;p&gt;The agent is a premise-taker. It will interrogate its own implementation all day and will not touch the frame of the request, because the frame came from you and you're the one it's trying to satisfy. "Did I do what was asked" is a question it can answer. "Was the ask right" isn't even in scope.&lt;/p&gt;

&lt;p&gt;Four things I changed, in rough order of how much they've paid off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask for the decision, not the destination.&lt;/strong&gt; "Find where this belongs in the list, then add it" instead of "add it to the Office section". One extra clause moves the category judgement inside the task, where the agent will actually spend reasoning on it. When I name the destination, I've silently converted a decision into a constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primary source, or say it's unverified.&lt;/strong&gt; For anything about a UI, a config field, an API shape, a provider's semantics: read it this session and cite it, or label the answer as a guess. Plausible-sounding click paths are the most confident wrong answers I get, because nothing in the model's experience punishes a well-formed guess about a settings page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make it tell me what it assumed.&lt;/strong&gt; Every non-trivial task ends with what it had to assume and what it could not check. This is cheap and it has caught more than I expected, because the premise shows up in writing where I can see it, instead of staying implicit in a diff that looks fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the artifact that can disagree with me.&lt;/strong&gt; A screenshot, a recording, a pixel diff, a second measurement taken a different way. The GIF found the redaction bug for one reason: it didn't share any code with the thing it was checking. Now that project has a test that pins box positions against independently computed glyph metrics and then samples the burned pixels, which is the same idea with the human taken out of the loop. It's &lt;a href="https://github.com/alphanumericentity/pdfairlock/blob/main/e2e/redact-geometry.spec.ts" rel="noopener noreferrer"&gt;here&lt;/a&gt; if you want the shape of it, AGPL like the rest of it. If your check is built from the same assumption as the code it checks, you don't have a check, you have a second copy of your belief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a hook can't help here
&lt;/h2&gt;

&lt;p&gt;I tried to find the tripwire for this and there isn't one, which took me a while to accept.&lt;/p&gt;

&lt;p&gt;A hook fires on a single tool call and decides from that call's input alone. That works when the mistake is local: a dangerous shell command, a comment block, an edit to a file that should never change. A wrong premise doesn't look like that. It produces a sequence of individually reasonable calls, each one defensible on its own, all of them marching toward a thing that shouldn't have been built. There's no string to match, no file path to guard, no moment where the input looks wrong.&lt;/p&gt;

&lt;p&gt;Determinism needs a decidable question. "Is the Office section the right home for a PDF tool" is not one.&lt;/p&gt;

&lt;p&gt;So the fix isn't mechanical. It's that the review has to happen earlier than I was doing it, before the work rather than on the diff. For agent work I'd now go further: reviewing the plan is worth more than reviewing the code, because the code is usually right. The expensive errors arrive fully formed, well-tested and pointed in the wrong direction.&lt;/p&gt;

&lt;p&gt;That's the part I'd like to be argued with on. Do you actually review plans before the agent starts, and does it hold up, or does it just move the rubber stamp one step earlier? Because I notice I'm much better at approving a plan I half-read than at approving a diff I half-read, and I don't think that's a good sign.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your agent's instructions file is a suggestion. A hook is a contract.</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:12:06 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/your-agents-instructions-file-is-a-suggestion-a-hook-is-a-contract-1eak</link>
      <guid>https://dev.to/alphanumericentity/your-agents-instructions-file-is-a-suggestion-a-hook-is-a-contract-1eak</guid>
      <description>&lt;p&gt;Every coding agent now reads an instructions file. &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, whatever yours is called. Mine is long. It has been long for months, because every time something went wrong I did the obvious thing and wrote a sentence about it.&lt;/p&gt;

&lt;p&gt;Here is what I eventually noticed. The agent follows those sentences most of the time. Not always. And the times it doesn't are not random: they're the moments when the file is long, the task is interesting, and the rule is the least salient thing in a 30k-token context. Which is exactly when you need the rule.&lt;/p&gt;

&lt;p&gt;A rule in prose competes with every other sentence in the file for attention. A rule in a hook does not compete with anything. It just runs.&lt;/p&gt;

&lt;p&gt;So over a few months I moved the rules that actually mattered out of the instructions file and into &lt;code&gt;PreToolUse&lt;/code&gt; hooks. There are about a dozen now. The agent is noticeably less annoying to supervise, and I stopped repeating myself.&lt;/p&gt;

&lt;p&gt;The examples below are Claude Code hooks, because that's what I use. The idea is the part worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanics are almost insultingly simple
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;PreToolUse&lt;/code&gt; hook is a program. The agent is about to call a tool, your program gets the call as JSON on stdin, and you decide.&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;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&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;json&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&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;stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;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;tool_input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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;command&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&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;block&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;reason&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;No DELETE against this database from an agent session. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Write the statement into a migration and let a human run it.&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make this better than a code review or a nag in the prompt:&lt;/p&gt;

&lt;p&gt;The call never happens. Not "the model is discouraged from it". The tool returns your refusal instead of running.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;reason&lt;/code&gt; goes back to the model, not to you. That's the part I underrated. A good reason makes the agent correct itself and carry on, so a blocked call costs a few seconds instead of a round trip through your attention. A bad reason ("blocked") makes it guess, flail, or ask you. The reason string is the whole product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tripwires
&lt;/h2&gt;

&lt;p&gt;Grouped by what they're actually protecting, because the categories turned out to matter more than the specifics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that can't be undone.&lt;/strong&gt; A guard that makes writes to the production database impossible from any agent session: mass deletes, truncates, cache flushes, a session-level &lt;code&gt;SET&lt;/code&gt; through a connection pooler. A guard that blocks merge commands, because merging is a human's decision and an agent that can merge will eventually merge something at 2am. A guard that refuses &lt;code&gt;sed -i&lt;/code&gt; into source files, which is how an agent rewrites a file without the edit ever passing through the checks on the edit tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are expensive.&lt;/strong&gt; A guard that blocks local type-checking entirely. That one sounds mad until you know that a cold run across this monorepo costs about fifty seconds of CPU and one process peaked at 5.6 GB, and that several agents in several worktrees will happily do it at once on a 24 GB laptop. CI type-checks every push anyway. The hook's reason says so, so the agent commits instead of grinding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are silently wrong.&lt;/strong&gt; My favourite hook, and the shortest. Our analytics query language reads a bare timestamp literal in the project's timezone, not UTC. Write &lt;code&gt;timestamp &amp;gt; '2026-07-03 00:00'&lt;/code&gt; and you have quietly shifted your window by ten hours. It once made a feature flag look like it was ramping when it wasn't, which produced a confident wrong diagnosis and a needless experiment reset. Now:&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;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(timestamp|created_at|_at)\s*[&amp;lt;&amp;gt;=]+\s*&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(\d{4}-\d{2}-\d{2}[^&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]*)&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([+-]\d{2}:?\d{2}|Z)\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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# explicit offset, fine
&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&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;block&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TZ_TRAP_EXPLANATION&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty lines, including the explanation. That class of bug cannot reach me any more, from any session, in any file or shell command, whether or not anybody remembered the rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things that are about taste, and therefore hopeless in prose.&lt;/strong&gt; A guard that blocks any edit adding a multi-line comment block. I had written "do not write comments" in the instructions file in capital letters, with reasons, twice. It did not work reliably, because the model's prior for "explain this clever bit" is strong and my sentence was one sentence. The hook works every time, and its reason tells the agent to rewrite the block as one line rather than just deleting the thought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things about process.&lt;/strong&gt; A guard on new UI that requires a stable test id, because the e2e suite selects by test id and a renamed element silently stops testing anything. And a guard that refuses to push a branch until an adversarial review receipt exists for the current head. The second one is the most aggressive thing in my setup, and I'd defend it: an agent that is sure its work is ready is not evidence that the work is ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;I don't want to sell this as free.&lt;/p&gt;

&lt;p&gt;False positives are real and they're the price. My shell guards block patterns that are usually footguns and occasionally the right thing, and then I'm reading a refusal about a command I meant to run. The honest fix is to go edit the hook, which I do, and which is still cheaper than the alternative because the fix is a diff somebody can review rather than another sentence in a file nobody re-reads.&lt;/p&gt;

&lt;p&gt;They're also per-repo and they rot. A hook that encodes "CI runs this check" is wrong the day CI stops. Mine have tests, which felt absurd when I wrote them and does not any more.&lt;/p&gt;

&lt;p&gt;And they don't replace the instructions file. Taste, context, architecture, the reason a weird thing is weird: that's all still prose, and prose is still the right shape for it. Hooks are for the small number of rules where "usually" isn't good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I actually changed my mind about
&lt;/h2&gt;

&lt;p&gt;I started doing this because I didn't trust the model. That was the wrong frame, and it made me write mean little guards with bad reasons.&lt;/p&gt;

&lt;p&gt;The better frame is that an agent has no way to know which of your forty rules is the one that costs money. You do. A hook is how you say "this one is not a preference", and a good reason string is how you teach instead of scold. The agent reads it, understands why, fixes its own command, and moves on. That's a better loop than any amount of bold text in a markdown file.&lt;/p&gt;

&lt;p&gt;So: what's on your deny list? I'm especially curious about the opposite view, because one rule I keep going back and forth on is whether blocking &lt;code&gt;git push&lt;/code&gt; crosses from guardrail into busywork. Tell me I'm wrong about that one.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A SELECT that returned more rows than its LIMIT</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Wed, 30 Sep 2026 10:11:54 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/a-select-that-returned-more-rows-than-its-limit-42kl</link>
      <guid>https://dev.to/alphanumericentity/a-select-that-returned-more-rows-than-its-limit-42kl</guid>
      <description>&lt;p&gt;A scheduled job in one of our services started crashing every few days with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Cannot read properties of undefined (reading 'id')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside a &lt;code&gt;for..of&lt;/code&gt; over the rows of a query. Not on the first row, not on every run, and never reproducible locally.&lt;/p&gt;

&lt;p&gt;The other clue turned up in a log line from the same job, which prints how many pending records it picked up. The query has &lt;code&gt;LIMIT 500&lt;/code&gt;. The log said &lt;code&gt;pendingTotal: 534&lt;/code&gt;. Another run said 813.&lt;/p&gt;

&lt;p&gt;A SELECT with a LIMIT of 500 returning 813 rows is the kind of thing that makes you go back and read your own SQL four times. The SQL was fine. So was the ORM. The bug was one line below the driver's surface, and it has been open upstream for about a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;We use &lt;a href="https://github.com/porsager/postgres" rel="noopener noreferrer"&gt;postgres-js&lt;/a&gt; (the &lt;code&gt;postgres&lt;/code&gt; package) with a connection pool, like most people do. Here's the shape of the bug, straight out of &lt;code&gt;src/connection.js&lt;/code&gt; in the current release:&lt;/p&gt;

&lt;p&gt;A connection keeps a single write cursor for the result it's filling in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;DataRow&lt;/code&gt; message the server sends gets written at that cursor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DataRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rows&lt;/code&gt; goes back to zero in exactly two places: &lt;code&gt;CommandComplete&lt;/code&gt;, which is the server saying the query finished, and &lt;code&gt;PortalSuspended&lt;/code&gt;, which is the cursor case. That's it.&lt;/p&gt;

&lt;p&gt;Now look at what happens when a query &lt;em&gt;doesn't&lt;/em&gt; finish. &lt;code&gt;ErrorResponse&lt;/code&gt; records the error and returns. Then &lt;code&gt;ReadyForQuery&lt;/code&gt; cleans up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ReadyForQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... resolve or reject the query ...&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;connectTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It allocates a fresh result array. One line later than where the cursor should have been reset, and the cursor isn't in that list.&lt;/p&gt;

&lt;p&gt;So: a query streams 97 rows, then the server kills it. &lt;code&gt;rows&lt;/code&gt; is left at 97. The connection goes back in the pool. The next query that lands on it gets a brand new empty array, writes its two rows at index 97 and 98, and resolves with an array whose &lt;code&gt;length&lt;/code&gt; is 99 and whose first 97 slots are holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this one is nastier than a normal wrong answer
&lt;/h2&gt;

&lt;p&gt;A sparse array is a bad failure mode because nothing about it throws:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;JSON.stringify(rows)&lt;/code&gt; gives you a list starting with 97 &lt;code&gt;null&lt;/code&gt;s. That goes straight into a log line or an API response.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;for..of&lt;/code&gt; and &lt;code&gt;.entries()&lt;/code&gt; hand you &lt;code&gt;undefined&lt;/code&gt;, which is where our TypeError came from.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.length&lt;/code&gt; is 99, which is how a &lt;code&gt;LIMIT 500&lt;/code&gt; query reports 534.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.map()&lt;/code&gt; preserves the holes, so an ORM mapping layer passes them through.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.filter(Boolean)&lt;/code&gt; hides it completely, so whether you ever find out depends on which array method you happened to use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the quiet one, which is the reason I'm writing this down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&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;notFound&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;row&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the record exists, and you take the "not found" branch. No error, no log, just a wrong decision. We had a version of that on a write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trigger is almost always a statement timeout
&lt;/h2&gt;

&lt;p&gt;To hit this you need a query that errors &lt;em&gt;after&lt;/em&gt; it has streamed at least one row. The common way is &lt;code&gt;statement_timeout&lt;/code&gt;, Postgres error 57014, firing mid-scan on a query that's already returning rows.&lt;/p&gt;

&lt;p&gt;The thing to check is where your timeout is set. Ours is on the database role, not in application code, which means any slow query in any service arms this for the next query on that connection. One heavy scan in a cron job, and an unrelated request handler sharing the pool gets the holes. That's also why it never reproduced locally: on a laptop nothing takes long enough to get cancelled.&lt;/p&gt;

&lt;p&gt;If you want to confirm it in your own logs, look for these two together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A count in a log line that exceeds the &lt;code&gt;LIMIT&lt;/code&gt; of the query it came from.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;TypeError&lt;/code&gt; on &lt;code&gt;undefined&lt;/code&gt; inside a loop over query results, within a few seconds of a &lt;code&gt;canceling statement due to statement timeout&lt;/code&gt; in the same process.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same process matters, since the pool lives in the process. Different pod, different pool, unrelated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading doesn't fix it
&lt;/h2&gt;

&lt;p&gt;I checked the current release and master while writing this. &lt;code&gt;postgres@3.4.9&lt;/code&gt; is the latest on npm, and both it and master reset the cursor in the same two places and no others. The fix is a one-line PR, &lt;a href="https://github.com/porsager/postgres/pull/1120" rel="noopener noreferrer"&gt;#1120&lt;/a&gt;, open since October 2025. The mechanism is written up in &lt;a href="https://github.com/porsager/postgres/issues/1181" rel="noopener noreferrer"&gt;issue #1181&lt;/a&gt;, which is open and still getting comments this week.&lt;/p&gt;

&lt;p&gt;So until it lands, patch it. With pnpm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm patch postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the one line to &lt;code&gt;ReadyForQuery&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   query = results = errorResponse = null
   result = new Result()
&lt;span class="gi"&gt;+  rows = 0
&lt;/span&gt;   connectTimer.cancel()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;pnpm patch-commit &amp;lt;dir&amp;gt;&lt;/code&gt;, which writes the patch file and the &lt;code&gt;patchedDependencies&lt;/code&gt; entry into your workspace. npm and yarn both have their own equivalents.&lt;/p&gt;

&lt;p&gt;If you'd rather not patch a dependency, the other mitigations are all worse but worth knowing: set &lt;code&gt;max: 1&lt;/code&gt; so a poisoned connection only hurts the code that poisoned it (it doesn't make the problem go away), drop your &lt;code&gt;statement_timeout&lt;/code&gt; low enough that queries die before streaming anything (good luck), or check &lt;code&gt;rows.length&lt;/code&gt; against your own LIMIT on every read and throw. We patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson I took
&lt;/h2&gt;

&lt;p&gt;I had a rule in my head that an error on a connection is contained: the transaction rolls back, the pool hands the connection to someone else, life continues. This was a reminder that the &lt;em&gt;client library&lt;/em&gt; also has per-connection state, and an error path is exactly where that state stops being maintained.&lt;/p&gt;

&lt;p&gt;So when a client hands you a fresh object for each query, the question worth asking is whether anything else is still pointing into the old one.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>node</category>
      <category>javascript</category>
      <category>database</category>
    </item>
    <item>
      <title>Give every coding agent its own git worktree</title>
      <dc:creator>alphanumericentity</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:58:28 +0000</pubDate>
      <link>https://dev.to/alphanumericentity/give-every-coding-agent-its-own-git-worktree-578p</link>
      <guid>https://dev.to/alphanumericentity/give-every-coding-agent-its-own-git-worktree-578p</guid>
      <description>&lt;p&gt;Quick scenario. You've got two coding agents going in two terminals. Agent one is halfway through a refactor. Agent two gets asked to fix a bug that lives on another branch, so it does the obvious thing and runs &lt;code&gt;git checkout&lt;/code&gt;. Agent one's next tool call reads files that aren't there anymore. Neither of them notices. Both keep going.&lt;/p&gt;

&lt;p&gt;I've had some version of this happen more than once before I stopped and fixed it properly. The problem is that a git checkout was designed for one person with one intention at a time. You'd never switch branches while a coworker was typing in the same directory, but an agent has no concept of "someone else is using this". It assumes the tree it saw a minute ago is the tree it has now. Often that's just false.&lt;/p&gt;

&lt;p&gt;The fix turned out to be a git feature I'd basically ignored for ten years: worktrees. They've been in git since 2.5 (2015). One repo, multiple working directories, each on a seperate branch, all sharing the same object store. One human rarely needs two checkouts of the same repo so nobody bothered. A handful of agents need exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ok so what's a worktree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch origin main
git worktree add &lt;span class="nt"&gt;-b&lt;/span&gt; fix/login-redirect ../myrepo-wt-login-redirect origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes a branch off the current &lt;code&gt;origin/main&lt;/code&gt;, checks it out in a sibling directory, and registers it with the repo. Leave off the &lt;code&gt;-b&lt;/code&gt; if the branch already exists. &lt;code&gt;git worktree list&lt;/code&gt; shows you all of them, &lt;code&gt;git worktree remove ../myrepo-wt-login-redirect&lt;/code&gt; gets rid of one.&lt;/p&gt;

&lt;p&gt;Same &lt;code&gt;.git&lt;/code&gt;, many directories. A branch can only be checked out in one worktree at a time, which sounds like a limitation but is actually the whole point. The second agent that tries to grab a branch gets an error instead of silently pulling the rug out.&lt;/p&gt;

&lt;p&gt;One thing: put the worktree next to the repo, not inside it. If it's inside, the main checkout sees a big new untracked directory and sooner or later someone's &lt;code&gt;git add .&lt;/code&gt; swallows it, and that's a fun one to untangle.&lt;/p&gt;

&lt;p&gt;That's the mechanism. The mechanism alone doesn't save you though, the rules around it do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four rules
&lt;/h2&gt;

&lt;p&gt;These live in the agent's instruction file (CLAUDE.md, AGENTS.md, whatever your tool reads). Not in my head, because I forget, and the agent definitely doesn't read my head.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Isolate.&lt;/strong&gt; Every edit happens in a dedicated worktree created off the exact base you mean. New change? Fetch and base it off &lt;code&gt;origin/main&lt;/code&gt;. Fixing an open PR? Base it off that PR's current remote head, not whatever stale local copy of the branch happens to be lying around. The main checkout is read only for agents. Grep in it, &lt;code&gt;git log&lt;/code&gt; in it, don't edit in it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stage by path.&lt;/strong&gt; &lt;code&gt;git add -A&lt;/code&gt; and &lt;code&gt;git add .&lt;/code&gt; are banned. The tree might have another agent's half done work in it, or a stray &lt;code&gt;.env&lt;/code&gt;, or some debug script. An agent that stages everything commits everything. It names the files it touched and those go in, nothing else.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the diff. Twice.&lt;/strong&gt; Before pushing, &lt;code&gt;git diff origin/main...HEAD&lt;/code&gt; (three dots, not two, this matters) has to be exactly the change you intended, nothing extra pulled in and nothing missing. Then again before merging, because main moved while the agent was working and the diff that got reviewed at push time is not the diff that actually lands. If anything unexpected shows up the agent stops and says so. It does not merge and hope.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clean up.&lt;/strong&gt; &lt;code&gt;git worktree remove&lt;/code&gt; once the PR is merged, then delete the branch. Old worktrees that outlive their PR are exactly where the next agent finds a stale branch and builds on top of it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's roughly what's in my instructions file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Changing code&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never edit in the main checkout. Work in a worktree off the intended base:
  git -C /abs/path/to/repo fetch origin main
  git -C /abs/path/to/repo worktree add -b &lt;span class="nt"&gt;&amp;lt;branch&amp;gt;&lt;/span&gt; /abs/path/to/repo-wt-&lt;span class="nt"&gt;&amp;lt;slug&amp;gt;&lt;/span&gt; origin/main
&lt;span class="p"&gt;-&lt;/span&gt; Stage by path. &lt;span class="sb"&gt;`git add -A`&lt;/span&gt; and &lt;span class="sb"&gt;`git add .`&lt;/span&gt; are forbidden.
&lt;span class="p"&gt;-&lt;/span&gt; Before pushing, and again before merging, &lt;span class="sb"&gt;`git diff origin/main...HEAD`&lt;/span&gt;
  must be exactly the intended change. Anything else: stop and report.
&lt;span class="p"&gt;-&lt;/span&gt; Update a pushed branch by merging main into it, never by rebasing.
&lt;span class="p"&gt;-&lt;/span&gt; Remove the worktree once the PR is merged.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there are on purpose.&lt;/p&gt;

&lt;p&gt;Absolute paths and &lt;code&gt;git -C&lt;/code&gt; everywhere. An agent's working directory is not something you want to depend on. Some harnesses reset it between tool calls, some (Claude Code, in my setup) prompt you every time a command starts with &lt;code&gt;cd&lt;/code&gt;, and an agent that thinks it's in the worktree but is actually sitting in the main checkout will do precisely the thing the worktree was supposed to prevent. Absolute paths make that whole class of bug impossible.&lt;/p&gt;

&lt;p&gt;And merge, don't rebase, once a branch has been pushed. A rebase rewrites commits that already exist on the remote, then needs a force push, and the force push throws away anything a human added to the branch in the meantime. An "update branch" click, a review fix, a commit from a coworker who had no idea an agent was on that branch. I know rebasing gives you a prettier history. I don't care anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use branches?
&lt;/h2&gt;

&lt;p&gt;Fair question, git already has branches.&lt;/p&gt;

&lt;p&gt;Because switching a branch is a write to a shared mutable global. Every agent in that checkout sees it and none of them get told. It also trashes everything in the tree that's keyed to its contents: build output, the bundler cache, tsc's incremental state, &lt;code&gt;node_modules&lt;/code&gt; if the branches have different deps. Two agents ping ponging between two branches in one directory spend most of their time rebuilding the world and very little of it doing the task.&lt;/p&gt;

&lt;p&gt;Also you can't run two dev servers from two branches out of one directory. With worktrees you can, and you'll want to, because "does the fix actually work" means running the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff that will bite you
&lt;/h2&gt;

&lt;p&gt;The mechanism is simple. The edges are where the afternoon goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untracked files don't come along.&lt;/strong&gt; A new worktree is a clean checkout of tracked files only. Your &lt;code&gt;.env&lt;/code&gt;, local config, that cert you generated months ago, none of it is there. Copy what the tree needs over explicitly. And don't let the agent "fix" a missing &lt;code&gt;.env&lt;/code&gt; by committing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;node_modules&lt;/code&gt; is per worktree.&lt;/strong&gt; Every tree needs its own install. With pnpm this is nearly free, packages get hardlinked out of the global content addressable store so a second install is mostly bookkeeping. With npm it's a full copy every time, which adds up fast. If your monorepo has a build cache, the local part of it is per tree as well, so a remote cache is what makes worktree number two start fast rather than rebuild everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ports collide.&lt;/strong&gt; Two worktrees, same app, same port. Derive the port from the worktree name or pass it explicitly, and make the agent print the URL it actually ended up on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database is the next shared global.&lt;/strong&gt; Two agents, two worktrees, one local Postgres. The moment one of them runs a migration the other's app is talking to a schema it doesn't expect. Per branch databases (a database or schema named after the branch, created on first boot) close this hole. It's the same move as the worktree, one level down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks and config are shared.&lt;/strong&gt; Every worktree reads the same &lt;code&gt;.git/hooks&lt;/code&gt; and the same repo config. Good, the guard rails apply everywhere. But a hook that assumes it's running from the main checkout path will do weird things in a worktree. Use &lt;code&gt;git rev-parse --show-toplevel&lt;/code&gt; in hooks, never a hardcoded path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stashes are shared too.&lt;/strong&gt; The stash is a ref on the repo, not the worktree. An agent that stashes in one tree leaves a lovely surprise for whoever runs &lt;code&gt;git stash pop&lt;/code&gt; in another. Honestly just don't stash. Commit the WIP on the branch and squash it later, or don't, nobody cares.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lockfiles diverge.&lt;/strong&gt; Two worktrees each add a dependency and now the lockfile conflicts at merge time. Never hand merge a lockfile. Take either side, run install, commit whatever it produces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strays pile up.&lt;/strong&gt; If you &lt;code&gt;rm -rf&lt;/code&gt; a worktree directory git still thinks it's there. &lt;code&gt;git worktree prune&lt;/code&gt; clears the bookkeeping. &lt;code&gt;git worktree list&lt;/code&gt; is the first thing I run when anything git related is being weird. A branch that's checked out in some worktree also can't be deleted or checked out anywhere else until that worktree is gone, which is the first thing to check when an agent complains that a branch is "already checked out".&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Before, the checkout was a global. Everything in my agent setup had to reason about its state and none of it could, because none of them could see the others.&lt;/p&gt;

&lt;p&gt;Now the checkout is a parameter. Each agent gets its own, does its work there, proves what it did with a diff against the base, and the directory goes away when the PR merges. The main checkout just sits on &lt;code&gt;main&lt;/code&gt;, clean, and I mostly use it to read code.&lt;/p&gt;

&lt;p&gt;None of this is clever. It's one git command that's been sitting there for a decade plus four rules written down somewhere the agent will actually read them. Most of the damage agents were doing to each other's work just stopped, and whatever's left at least shows up in the diff.&lt;/p&gt;

</description>
      <category>git</category>
      <category>programming</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
