<?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: Srinivas Kondepudi</title>
    <description>The latest articles on DEV Community by Srinivas Kondepudi (@sirinivask).</description>
    <link>https://dev.to/sirinivask</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%2F3929806%2F3e582fb0-6ad8-4b28-bd40-b27808c07692.jpg</url>
      <title>DEV Community: Srinivas Kondepudi</title>
      <link>https://dev.to/sirinivask</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sirinivask"/>
    <language>en</language>
    <item>
      <title>Five Chron updates for AI audit trails you can inspect</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Sun, 25 Oct 2026 19:59:44 +0000</pubDate>
      <link>https://dev.to/sirinivask/five-chron-updates-for-ai-audit-trails-you-can-inspect-143b</link>
      <guid>https://dev.to/sirinivask/five-chron-updates-for-ai-audit-trails-you-can-inspect-143b</guid>
      <description>&lt;p&gt;An AI tool answering a prompt and an audit record being saved are two different events.&lt;/p&gt;

&lt;p&gt;That distinction has shaped our recent work on &lt;strong&gt;Chron&lt;/strong&gt;, a local-first logger for AI interactions. We want developers to inspect what was recorded, understand what a verification result establishes, and recover their evidence when something goes wrong.&lt;/p&gt;

&lt;p&gt;Here are five capabilities shipped across recent releases, available in &lt;strong&gt;Chron 0.1.64&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Capture before release, without asking the model to log
&lt;/h2&gt;

&lt;p&gt;MCP logging depends on the client calling a tool. Instructions can request that call, but they cannot make it inevitable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chron run&lt;/code&gt; introduces a different boundary. It records the child's controlled stdin, stdout and stderr in a local journal and makes the covering records durable before forwarding input or releasing output. If capture fails, it stops forwarding and releasing rather than quietly continuing without a record.&lt;/p&gt;

&lt;p&gt;For a small example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp@0.1.64
chron run &lt;span class="nt"&gt;--&lt;/span&gt; node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log('hello from chron')"&lt;/span&gt;
chron &lt;span class="nb"&gt;history
&lt;/span&gt;chron verify &lt;span class="nt"&gt;--journal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper also attempts to project the run into the database. If projection fails independently of capture, &lt;code&gt;chron project&lt;/code&gt; is the retry path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The limit matters:&lt;/strong&gt; this covers streams controlled by the wrapper. It does not force anyone to use the wrapper or capture everything a client does through files, subprocesses or network calls. Runs recorded with reduced directory durability do not receive the full &lt;code&gt;enforced:wrapper&lt;/code&gt; classification.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Backup and recovery that checks the evidence
&lt;/h2&gt;

&lt;p&gt;A logger should have a recovery path before it has a destructive command.&lt;/p&gt;

&lt;p&gt;Chron now takes SQLite snapshots using &lt;code&gt;VACUUM INTO&lt;/code&gt;, with a separate manifest describing the snapshot. Restore checks the file digest, database integrity and recorded evidence chains before installing the restored copy. Recovery drills exercise verification without replacing the live database.&lt;/p&gt;

&lt;p&gt;Confirmed pruning requires an explicit backup destination and refuses to delete if that backup fails.&lt;/p&gt;

&lt;p&gt;Backups and signed exports remain different tools: one restores a database; the other lets someone verify a portable evidence bundle. The backup manifest is unsigned, so its recorded creation time is not independently attested.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Reconcile what the client recorded with what Chron holds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;chron reconcile&lt;/code&gt; compares local Claude Code transcript records with inspected Chron evidence. It is read-only: it does not import, merge or silently upgrade the provenance of a session.&lt;/p&gt;

&lt;p&gt;The streaming reader can inspect large transcript files without retaining the whole file in memory. The comparison remains sampled, up to 200 messages per transcript, and reports its inspected scope and limits.&lt;/p&gt;

&lt;p&gt;An unmatched record means &lt;strong&gt;no correspondence was found within that comparison&lt;/strong&gt;. It is not proof that someone bypassed logging, and the correspondence fraction is not a capture-completeness score.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. OpenTelemetry export with delivery receipts
&lt;/h2&gt;

&lt;p&gt;Chron supports operator-initiated OTLP/HTTP JSON trace export for sessions and messages. Conversation content is excluded by default.&lt;/p&gt;

&lt;p&gt;The useful part is distinguishing outcomes. A collector refusing an export is not the same as a connection failing before delivery. A request received by a collector that never answers has an uncertain outcome.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chron otel receipts&lt;/code&gt; exposes the recorded delivery state rather than flattening those cases into a success tick. This is manual trace export, not automatic telemetry, metrics or log export. A collector response is not proof of permanent retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. HTTP transport defaults that are explicit
&lt;/h2&gt;

&lt;p&gt;For deployments using MCP over HTTP, Chron 0.1.64 requires authentication and binds to loopback by default. It validates Host and Origin, and non-loopback binding requires explicit configuration.&lt;/p&gt;

&lt;p&gt;The stdio path remains separate and does not require an HTTP API key.&lt;/p&gt;

&lt;p&gt;These are specific controls, not a blanket security claim. Dependency hardening is ongoing and is being scoped separately from feature work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification should explain its boundary
&lt;/h2&gt;

&lt;p&gt;The common thread is keeping the claim close to the evidence.&lt;/p&gt;

&lt;p&gt;A journal chain can establish internal consistency; an unanchored chain is not proof that nobody rewrote it. A successful package test does not establish that every AI client will invoke Chron. A local record does not establish that every interaction was captured.&lt;/p&gt;

&lt;p&gt;Our latest published-package regression passed 44 release checks and nine streaming checks. Real authenticated client acceptance remains a separate, unfinished test leg, not something those numbers establish.&lt;/p&gt;

&lt;p&gt;Chron is available on &lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, with source and documentation on &lt;a href="https://github.com/sirinivask/chron" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For teams using AI developer tools: which gap is hardest today: dependable capture, identifying missing records, or independently verifying the evidence you already have?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>observability</category>
      <category>security</category>
    </item>
    <item>
      <title>Chron crossed 11,000 downloads. Here's what the feedback is teaching us.</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:26:55 +0000</pubDate>
      <link>https://dev.to/sirinivask/chron-crossed-11000-downloads-heres-what-the-feedback-is-teaching-us-4aoa</link>
      <guid>https://dev.to/sirinivask/chron-crossed-11000-downloads-heres-what-the-feedback-is-teaching-us-4aoa</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46uubtoq1oxblet3zzno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46uubtoq1oxblet3zzno.png" alt="Chron" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Chron has reached &lt;strong&gt;11,148 total npm downloads&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Thank you to everyone who has installed it, tried it in a real workflow, asked difficult questions, or shared feedback across developer communities and platforms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI work deserves a record.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One important distinction before anything else: downloads are not unique users. Repeat installs, upgrades and automation all contribute. This is a distribution milestone, not a claim about how many people actively use Chron.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we're building Chron
&lt;/h2&gt;

&lt;p&gt;AI-assisted work is happening across terminals, editors and chat clients. When someone later asks what was requested, what the assistant returned, and when the interaction happened, reconstructing the answer can be surprisingly difficult.&lt;/p&gt;

&lt;p&gt;Chron is a local-first tool for recording AI interaction evidence and inspecting that record later. The aim is straightforward: make the evidence useful without claiming it establishes more than it does.&lt;/p&gt;

&lt;p&gt;The most useful feedback hasn't been about adding another chart. It has been about the boundary between a tool being configured and an interaction actually being recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three distinctions that matter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Configured is not captured.&lt;/strong&gt; An MCP connection can exist without the client making a logging call. Instruction-based logging still depends on the AI client choosing to call the tool. It isn't mandatory capture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recorded is not complete.&lt;/strong&gt; Having a session in the database doesn't establish that every interaction was recorded. Chron's Claude Code transcript reconciliation compares inspected transcript records with Chron evidence and reports the correspondence it finds. Its comparison is sampled, so that result must not be presented as a completeness percentage for the entire conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accepted is not retained.&lt;/strong&gt; When an operator exports traces through OTLP/HTTP, collector success establishes acceptance of the payload, not that the platform stored it. Chron's export receipts preserve that distinction.&lt;/p&gt;

&lt;p&gt;These aren't just wording preferences. They change how useful the record is when someone relies on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture without asking the model to remember
&lt;/h2&gt;

&lt;p&gt;Chron also provides a wrapper path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron run &lt;span class="nt"&gt;--&lt;/span&gt; &amp;lt;your-cli&amp;gt; &amp;lt;arguments&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the stdin, stdout and stderr channels it controls, the wrapper records data in its local journal before forwarding input or releasing output. This doesn't require the model to make a logging tool call.&lt;/p&gt;

&lt;p&gt;That guarantee has a boundary: it does not force someone to use the wrapper, and it does not capture arbitrary file writes, subprocess activity, network traffic or direct-terminal access outside those controlled channels.&lt;/p&gt;

&lt;p&gt;After projection, the session can be inspected through Chron's ordinary history and verification commands. Verification compares the projected messages with the journal evidence rather than trusting an enforcement label in a database field.&lt;/p&gt;

&lt;p&gt;The journal's hash chain establishes internal consistency. Because its head is not independently anchored, it is not proof that nobody could rewrite the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three released features to try
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Inspect capture scope, then wrap a CLI.&lt;/strong&gt; Start with the scope rather than assuming everything is covered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron run &lt;span class="nt"&gt;--scope&lt;/span&gt;
chron run &lt;span class="nt"&gt;--&lt;/span&gt; &amp;lt;your-cli&amp;gt; &amp;lt;arguments&amp;gt;
chron verify &lt;span class="nt"&gt;--journal&lt;/span&gt;
chron &lt;span class="nb"&gt;history&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the angle-bracket placeholders with your CLI and its arguments. The wrapper captures controlled terminal channels, not every action the client takes. CLI compatibility varies; this isn't a promise of support for every interactive client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Compare Claude Code transcripts with Chron evidence.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron reconcile
chron reconcile &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is read-only and currently specific to Claude Code transcripts. Up to 200 comparable messages per transcript are sampled; larger-file streaming doesn't remove that comparison limit. Correspondence is not an overall capture-completeness score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make a database backup and rehearse verification without replacing the live database.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron backup &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./chron-milestone-backup.db
chron restore &lt;span class="nt"&gt;--from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./chron-milestone-backup.db &lt;span class="nt"&gt;--drill&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose a new output path. The drill checks the snapshot without installing it. A database backup is not a backup of separate journal files or signing keys; preserve those separately when planning recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd like feedback on next
&lt;/h2&gt;

&lt;p&gt;We're continuing to work on reliability, capture coverage and making those boundaries easier to understand across AI workflows. Support varies by client and capture path; a configured integration is not a blanket guarantee.&lt;/p&gt;

&lt;p&gt;If you've tried Chron, which client are you using, and where does the record fall short of what you need? A concrete workflow, a confusing output, or an interaction that wasn't recorded is more useful than a broad feature vote.&lt;/p&gt;

&lt;p&gt;You can find the package on &lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;npm&lt;/a&gt; and the public repository on &lt;a href="https://github.com/sirinivask/chron" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Thank you for helping turn a download milestone into better engineering questions.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>observability</category>
      <category>discuss</category>
    </item>
    <item>
      <title>AI audit logging should not depend on AI remembering to log</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:12:22 +0000</pubDate>
      <link>https://dev.to/sirinivask/ai-audit-logging-should-not-depend-on-ai-remembering-to-log-1c7h</link>
      <guid>https://dev.to/sirinivask/ai-audit-logging-should-not-depend-on-ai-remembering-to-log-1c7h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua9vdnvbi3ibe5h6u3u9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua9vdnvbi3ibe5h6u3u9.png" alt="Chron" width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An AI client can have the right logging instructions, the right MCP configuration, and a reachable audit server, and still leave you with no record of the conversation.&lt;/p&gt;

&lt;p&gt;The model might skip a tool call. A permission policy might refuse it. A run might end before the logging call happens.&lt;/p&gt;

&lt;p&gt;We encountered these failure modes while testing Chron. They led to a change in where capture happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chron 0.1.62 introduces &lt;code&gt;chron run&lt;/code&gt;: a wrapper that records the stdin, stdout, and stderr channels it controls before forwarding or releasing their bytes.&lt;/strong&gt; Logging on that path does not require the model to call Chron.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move capture into the execution path
&lt;/h2&gt;

&lt;p&gt;Chron is a local-first tool for recording AI interactions and inspecting the evidence they leave behind. Its published npm package is available for end users; the source is visible for review under the project's license.&lt;/p&gt;

&lt;p&gt;Its MCP integration remains useful. A cooperating client can record messages and structured events, including tool calls and code changes. But a request to log is not an enforcement boundary.&lt;/p&gt;

&lt;p&gt;The wrapper introduces a different path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operator input -&amp;gt; journal write + fsync -&amp;gt; child stdin
Child stdout   -&amp;gt; journal write + fsync -&amp;gt; terminal
Child stderr   -&amp;gt; journal write + fsync -&amp;gt; terminal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The process doing the writing also controls forwarding and release. It waits for the covering write and &lt;code&gt;fsync&lt;/code&gt; to succeed. If a capture write fails, it stops forwarding, withholds pending output, and terminates the direct child.&lt;/p&gt;

&lt;p&gt;That ordering is the feature. Recording the same bytes after displaying them would leave a window in which the response reached the operator and the audit record did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the capture path
&lt;/h2&gt;

&lt;p&gt;Install the released package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp@0.1.62
chron version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Print the scope before wrapping a client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron run &lt;span class="nt"&gt;--scope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a small example that needs no AI account. It checks the input/output path with a child process that echoes what it receives:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'hello chron\n'&lt;/span&gt; | chron run &lt;span class="nt"&gt;--&lt;/span&gt; node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'process.stdin.on("data", b =&amp;gt; process.stdout.write(b))'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a headless Claude Code invocation that reads its prompt from stdin:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'Explain this repository in three sentences.\n'&lt;/span&gt; | chron run &lt;span class="nt"&gt;--&lt;/span&gt; claude &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client authentication and permissions still apply. This release controls inherited stdio pipes; it is not a promise of compatibility with every interactive terminal UI. Text supplied in command-line arguments is recorded as command metadata rather than as an input message.&lt;/p&gt;

&lt;p&gt;Inspect the resulting evidence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron &lt;span class="nb"&gt;history
&lt;/span&gt;chron verify &lt;span class="nt"&gt;--journal&lt;/span&gt;
chron verify &amp;lt;session-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Capture first, query second
&lt;/h2&gt;

&lt;p&gt;Wrapped runs go into an append-only, hash-chained local journal. Chron then projects completed runs into its session database for history and reporting.&lt;/p&gt;

&lt;p&gt;This ordering matters when the database is unavailable. A projection failure is reported separately from capture. The journal retains the evidence, and replay can reconstruct the session later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay is transactional per run and idempotent. Repeating it does not create another copy of an already projected session.&lt;/p&gt;

&lt;p&gt;The default journal is &lt;code&gt;~/.chron/capture.jsonl&lt;/code&gt;; the default database is &lt;code&gt;~/.chron/chron.db&lt;/code&gt;. Both remain under the operator's control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the enforcement label means
&lt;/h2&gt;

&lt;p&gt;Chron distinguishes two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provenance:&lt;/strong&gt; how did Chron obtain this evidence?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture mode:&lt;/strong&gt; what required recording on this path?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A client calling an MCP logging tool and a wrapper intercepting stdio can both produce witnessed evidence. They have different capture guarantees.&lt;/p&gt;

&lt;p&gt;A fully durable wrapped run can support &lt;code&gt;enforced:wrapper&lt;/code&gt;. Session verification checks its journal binding, run structure, and projected message content against the journal. Setting a label in database metadata is insufficient.&lt;/p&gt;

&lt;p&gt;Reduced-durability runs are explicitly distinguished and do not qualify for the same full guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is narrower than "everything the AI did"
&lt;/h2&gt;

&lt;p&gt;The wrapper controls stdin, stdout, and stderr. It does not capture file access, network requests, independent subprocess activity, or a terminal the client opens directly. Nor does it prevent someone from launching the client outside Chron.&lt;/p&gt;

&lt;p&gt;There is another distinction to keep clear: an internally consistent hash chain is not proof that nobody rewrote it. Someone able to replace the entire unanchored journal can recompute its hashes. Chron does not currently sign journal heads.&lt;/p&gt;

&lt;p&gt;The label therefore describes the controlled capture path. It is not a certification of every AI action or an organization-wide enforcement policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the failure paths too
&lt;/h2&gt;

&lt;p&gt;The useful tests were not just "does the output appear?" They were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was its covering record flushed before it appeared?&lt;/li&gt;
&lt;li&gt;Did a short write or failed sync stop release?&lt;/li&gt;
&lt;li&gt;Did an interrupted run remain distinguishable from a completed one?&lt;/li&gt;
&lt;li&gt;Could database projection fail without losing captured evidence?&lt;/li&gt;
&lt;li&gt;Did an edited projection lose its verified enforcement claim?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An independent manual regression against the published 0.1.62 package exercised capture, replay, rotation, signing, recovery, imports, and collector delivery. It also found two OTEL guidance issues: collector rejection was described as a request not reaching the collector, and retry guidance overstated deduplication guarantees. Both remain findings in that released version.&lt;/p&gt;

&lt;p&gt;That is the standard we want for an evidence tool: its claims should be small enough to test, and its limits should be visible alongside them.&lt;/p&gt;

&lt;p&gt;Chron is available on &lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, with the public project and documentation on &lt;a href="https://github.com/sirinivask/chron" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you try the wrapper, which client and failure scenario would you test first?&lt;/p&gt;

</description>
    </item>
    <item>
      <title>We're Enabling SaaS for CLAIIM. Sovereignty Still Matters.</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Fri, 09 Oct 2026 20:04:22 +0000</pubDate>
      <link>https://dev.to/sirinivask/were-enabling-saas-for-claiim-sovereignty-still-matters-21g</link>
      <guid>https://dev.to/sirinivask/were-enabling-saas-for-claiim-sovereignty-still-matters-21g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje3nfd1g6n4zlz82yfly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fje3nfd1g6n4zlz82yfly.png" alt="CLAIIM" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Trying an AI governance product should not require a production deployment decision on day one.&lt;/p&gt;

&lt;p&gt;That is why we are enabling SaaS for CLAIIM: to give teams an easier way to explore the product, experiment with agent workflows, and understand where runtime governance fits before committing to a deployment.&lt;/p&gt;

&lt;p&gt;But easier access should not make deployment control an afterthought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SaaS is an easier way to start. Sovereignty remains a design priority.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a direction update, not a general-availability announcement or a promise that every hosting and residency option is already available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem is bigger than another AI chat interface
&lt;/h2&gt;

&lt;p&gt;An organization might use several AI tools across engineering, operations, and internal workflows. Giving everyone another place to chat does not, by itself, govern what those agents can do.&lt;/p&gt;

&lt;p&gt;The harder questions begin when an agent wants to act:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is accountable for this agent?&lt;/li&gt;
&lt;li&gt;What authority has it been delegated?&lt;/li&gt;
&lt;li&gt;Which policy applies to this action?&lt;/li&gt;
&lt;li&gt;Can it proceed, must it stop, or does it need human approval?&lt;/li&gt;
&lt;li&gt;What was actually observed afterward?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CLAIIM is the runtime governance layer for AI agents: establishing who is acting, what they may do, whether an action can proceed, and what evidence remains afterward.&lt;/p&gt;

&lt;p&gt;The distinction matters: &lt;strong&gt;control the agent's capabilities and execution paths, not merely its conversation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A prompt asking an agent not to deploy to production is an instruction. A deployment endpoint that rejects requests without the required authorization is a boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we want SaaS to make easier
&lt;/h2&gt;

&lt;p&gt;Before a team evaluates governance infrastructure, it needs to see the workflow.&lt;/p&gt;

&lt;p&gt;Our goal with SaaS is to reduce the setup needed to explore agent identities, skills, policies, approvals, and the evidence trail in the CLAIIM console.&lt;/p&gt;

&lt;p&gt;A useful first experiment does not need production credentials. Start with a synthetic workflow and ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can we identify the acting agent and the accountable human?&lt;/li&gt;
&lt;li&gt;Can a policy distinguish routine work from sensitive work?&lt;/li&gt;
&lt;li&gt;Can a sensitive request enter an approval workflow?&lt;/li&gt;
&lt;li&gt;Can we follow the decision and recorded outcome afterward?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives developers and security teams something concrete to evaluate together, before connecting a real customer database or a deployment system.&lt;/p&gt;

&lt;p&gt;It also makes an important conversation possible early: &lt;strong&gt;where should this run when it becomes part of our production environment?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete example: an agent requests a deployment
&lt;/h2&gt;

&lt;p&gt;Consider an agent preparing a release. The following is a conceptual workflow, not an SDK example or a claim that every deployment platform is integrated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent requests an action
        |
        v
Resolve identity, delegated scope, and applicable policy
        |
        v
Runtime decision: ALLOW / DENY / HOLD
        |
        +-- DENY: reject the governed request
        |
        +-- HOLD: require the configured human approval
        |
        +-- ALLOW: permit the controlled execution path
                         |
                         v
                 Record the observed result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CLAIIM, configured HOLD actions support human approval, including one-person or ordered two-person approval. That does not mean every action requires a human; the applicable policy determines when approval is needed.&lt;/p&gt;

&lt;p&gt;There is another qualification that should never disappear in a product demo: an ALLOW or DENY decision is not automatically enforcement.&lt;/p&gt;

&lt;p&gt;If the agent can bypass the governed path using another credential or an unrestricted network route, the organization has a policy decision, not a complete execution boundary. Stronger enforcement depends on the surrounding credential, tool, and infrastructure controls too.&lt;/p&gt;

&lt;p&gt;Likewise, an HTTP success response is not independent proof that the intended downstream state exists. A recorded outcome and a verified outcome are different things.&lt;/p&gt;

&lt;p&gt;Our governing rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;CLAIIM must never represent a decision as enforcement, or a reported outcome as verified evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SaaS does not change that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sovereignty is more than a hosting label
&lt;/h2&gt;

&lt;p&gt;For us, sovereignty means taking deployment and operational authority seriously. It requires concrete answers about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where governance data is stored and processed.&lt;/li&gt;
&lt;li&gt;Who administers the deployment and can access its records.&lt;/li&gt;
&lt;li&gt;How credentials and sensitive inputs are handled.&lt;/li&gt;
&lt;li&gt;Which external services receive data.&lt;/li&gt;
&lt;li&gt;How retention, export, and deletion work.&lt;/li&gt;
&lt;li&gt;What happens when the governance service is unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are evaluation questions, not a list of capabilities we are claiming every SaaS configuration already satisfies.&lt;/p&gt;

&lt;p&gt;A hosted trial may be a useful place to learn with synthetic data. Production adoption needs a separate assessment of the deployment, data flows, and enforcement paths involved.&lt;/p&gt;

&lt;p&gt;Sovereign deployment remains a priority as we enable SaaS. Convenience should help a team discover the product, not silently decide its long-term trust model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The console operates the governance layer
&lt;/h2&gt;

&lt;p&gt;The CLAIIM console brings together agents, organizations, skills, policies, grants, governance packs, gateway configuration, users, access, approvals, Chron evidence, proof exports, and settings.&lt;/p&gt;

&lt;p&gt;These screens help teams inspect and operate governance. They are not the boundary on their own.&lt;/p&gt;

&lt;p&gt;The product direction remains:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Runtime Gate is the product. The Console operates it. The Workspace is an optional experience built on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That keeps us focused on governed actions and evidence rather than competing to become everyone's universal AI interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Easier to try. Deliberate about trust.
&lt;/h2&gt;

&lt;p&gt;We want teams to reach their first useful governance experiment sooner, then make an informed decision about production deployment.&lt;/p&gt;

&lt;p&gt;That is the purpose of enabling SaaS for CLAIIM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make AI easy to try. Keep authority yours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are evaluating agent governance, what would you test first: delegated authority, approval workflows, execution boundaries, or the evidence trail?&lt;/p&gt;

&lt;p&gt;Learn more at &lt;a href="https://claiim.io" rel="noopener noreferrer"&gt;claiim.io&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Chron passed 10,000 npm downloads on September 30th. 10,158 as of this morning</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Sun, 04 Oct 2026 21:14:21 +0000</pubDate>
      <link>https://dev.to/sirinivask/chron-passed-10000-npm-downloads-on-september-30th-10158-as-of-this-morning-k0h</link>
      <guid>https://dev.to/sirinivask/chron-passed-10000-npm-downloads-on-september-30th-10158-as-of-this-morning-k0h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3x47bob9ryeacu89fe0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3x47bob9ryeacu89fe0.png" alt="Chron" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Chron passed 10,000 npm downloads on September 30th. 10,158 as of this morning, five months after the first publish on May 8th.&lt;/p&gt;

&lt;p&gt;I build a tool whose only job is to tell the difference between &lt;em&gt;this was recorded&lt;/em&gt; and &lt;em&gt;this was verified&lt;/em&gt;. So before writing a milestone post, I pointed it at the milestone.&lt;/p&gt;

&lt;p&gt;The number did not survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the number is
&lt;/h2&gt;

&lt;p&gt;Here it is, and you can run this yourself:&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="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.npmjs.org/downloads/range/2026-04-01:2026-10-04/chron-mcp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(sum(x['downloads'] for x in json.load(sys.stdin)['downloads']))"&lt;/span&gt;
&lt;span class="c"&gt;# 10158&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real figure from npm's own API. I am not disputing it. The question is a different one: &lt;strong&gt;what does it establish?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the number establishes
&lt;/h2&gt;

&lt;p&gt;Almost nothing about whether anyone uses Chron.&lt;/p&gt;

&lt;p&gt;Here are the six biggest download days in its history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-05-08   630
2026-07-14   531
2026-08-13   519
2026-05-12   457
2026-07-09   403
2026-05-28   369
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is what I published on each of those days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-05-08   0.1.0, 0.1.1, 0.1.2, 0.1.3, 0.1.4, 0.1.5
2026-07-14   0.1.32, 0.1.33
2026-08-13   0.1.50, 0.1.51, 0.1.52
2026-05-12   0.1.9, 0.1.10, 0.1.11, 0.1.12
2026-07-09   0.1.30, 0.1.31
2026-05-28   0.1.18, 0.1.19, 0.1.20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six for six. Every record day is a day &lt;strong&gt;I&lt;/strong&gt; pushed a release. The all-time peak is launch day, before anyone had heard of it.&lt;/p&gt;

&lt;p&gt;The monthly shape says the same thing. The biggest month was the month I shipped the first version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-05    2523
2026-06    1134
2026-07    2307
2026-08    2294
2026-09    1758
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strip the release-day spikes and the median day over the last thirty days is &lt;strong&gt;34.5 downloads&lt;/strong&gt;. Forty-five of the last hundred and eighty-seven days had zero.&lt;/p&gt;

&lt;p&gt;So: a number that goes up when I act, and sits near thirty-five when I don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Marking the inference
&lt;/h2&gt;

&lt;p&gt;Now the part I have to be careful about, because it is the whole point of the post.&lt;/p&gt;

&lt;p&gt;I &lt;em&gt;believe&lt;/em&gt; most of those spikes are registry mirrors, vulnerability scanners and CI caches reacting to a new version. That belief is reasonable. It is also an &lt;strong&gt;inference&lt;/strong&gt;, and npm's public API gives me no way to test it — there is no breakdown by client, no distinction between a human running &lt;code&gt;npm i&lt;/code&gt; and a crawler indexing a tarball.&lt;/p&gt;

&lt;p&gt;So the honest summary is not "10,000 downloads are bots". It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Established:&lt;/strong&gt; 10,158 tarball fetches. Every peak day is a release day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inferred:&lt;/strong&gt; much of the volume is automated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown:&lt;/strong&gt; how many people installed Chron and kept it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three categories, and the headline number lives in the weakest one. A milestone post that printed "10,000 developers" would have moved a fact from the third row into the first by writing a sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I care about this specific mistake
&lt;/h2&gt;

&lt;p&gt;Because I have spent the last two weeks finding it in my own code, over and over, in exactly one shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A check confirmed something was present, and reported it as a claim that it worked.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The clearest example shipped in the release I was testing when I started writing this.&lt;/p&gt;

&lt;p&gt;Chron now has &lt;code&gt;chron restore --drill&lt;/code&gt; — a recovery rehearsal. It verifies a backup, reports what the backup is missing relative to your live database, and touches nothing. It ends with this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Drill only. The live database was not touched.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I measured that sentence. I built a database whose write-ahead log held a committed transaction, killed the process so the log was left unrecovered, hashed all three SQLite files, ran the drill from the packed tarball, and hashed them again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before   db=cba17c04/163840   -wal=e854fde3/12392   -shm=b7fc33da/32768
after    db=35bc3010/163840   -wal=ABSENT           -shm=ABSENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main file's digest changed. &lt;strong&gt;Both sidecars were deleted.&lt;/strong&gt; A &lt;code&gt;-wal&lt;/code&gt; holds transactions that are committed but not yet folded into the main file, so for a few hundred milliseconds that drill was the single most dangerous command in the product — and it said it had done nothing.&lt;/p&gt;

&lt;p&gt;The cause is not a careless &lt;code&gt;UPDATE&lt;/code&gt;. Reading a write-ahead log that needs recovery &lt;em&gt;requires writing&lt;/em&gt;: SQLite has to rebuild the log index to see what the log contains, and the last connection to close checkpoints the log into the main file and removes the sidecars. I tried &lt;code&gt;PRAGMA query_only = ON&lt;/code&gt;. I tried libsql's &lt;code&gt;readonly: true&lt;/code&gt;. I measured both against a killed database. Both checkpointed. Recovery is not a statement, so a statement-level guard cannot reach it.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: copy the database and its log to a scratch path, read the copy, delete it. The live files are then provably untouched, at the cost of temporary disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE   db=89d729fb/163840   -wal=531a0aee/12392   -shm=7cb7830f/32768
         ! 1 of 2 live session(s) are absent from this backup
         Drill only. The live database was not touched.
AFTER    db=89d729fb/163840   -wal=531a0aee/12392   -shm=7cb7830f/32768
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same three digests. And still reading through the log — it found the session that exists &lt;em&gt;only&lt;/em&gt; in the log, so it is not passing by refusing to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that proved nothing
&lt;/h2&gt;

&lt;p&gt;One more, because it is the same bug wearing a lab coat.&lt;/p&gt;

&lt;p&gt;My first regression test hashed the files before and after a drill and asserted they matched. It passed. Then I restored the broken code to check the test would catch it.&lt;/p&gt;

&lt;p&gt;It still passed.&lt;/p&gt;

&lt;p&gt;Inside a test runner the process never exits, so the connection never closes, so the checkpoint never happens. The test was watching exactly the right place and would never have seen anything occur. It took a child process — and an assertion on the &lt;code&gt;-shm&lt;/code&gt; digest, the one part of the damage that &lt;em&gt;is&lt;/em&gt; visible in-process — to make it real.&lt;/p&gt;

&lt;p&gt;A test that cannot fail is a claim, not a check. Which is the same sentence as the one about downloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would rather count
&lt;/h2&gt;

&lt;p&gt;Chron is a local-first audit log: it records what your AI coding tools actually did, on your machine, in a database you own, with hash-chained records you can verify offline. Nothing leaves your disk unless you explicitly export it.&lt;/p&gt;

&lt;p&gt;That design is the right one, and it means &lt;strong&gt;I cannot see usage&lt;/strong&gt;. No telemetry, no phone-home, no install ping. The number I can see is the one npm hands me, and the number I would actually want — how many people ran a verify and got a clean chain back — is one I have deliberately made it impossible for myself to know.&lt;/p&gt;

&lt;p&gt;I am not going to fix that by adding telemetry to an audit tool. So the honest position is that I have a download count, I know what it is worth, and I am going to say so rather than round it up into a victory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual milestone
&lt;/h2&gt;

&lt;p&gt;Five months. Forty-five releases. One recurring defect shape, now written on the wall where I can see it.&lt;/p&gt;

&lt;p&gt;The operating principle for the product is &lt;em&gt;Chron records, it does not certify&lt;/em&gt; — it hands you evidence and leaves the judgement to you. It would be a strange thing to write that on the box and then certify my own growth from a number I cannot audit.&lt;/p&gt;

&lt;p&gt;10,158 tarball fetches. Median real day, about thirty-five. Thanks to whichever of you are the thirty-five.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>npm</category>
      <category>tools</category>
    </item>
    <item>
      <title>Every bug we found this release was the same bug</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:40:29 +0000</pubDate>
      <link>https://dev.to/sirinivask/every-bug-we-found-this-release-was-the-same-bug-43em</link>
      <guid>https://dev.to/sirinivask/every-bug-we-found-this-release-was-the-same-bug-43em</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog79bgaxanxgks8n5tok.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fog79bgaxanxgks8n5tok.png" alt="Chron" width="800" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build an audit tool. Its whole proposition is that its records can be trusted. So the defects we found in the last release were embarrassing in a specific way: almost every one of them was the tool claiming something it had not established.&lt;/p&gt;

&lt;p&gt;They were written months apart, by different people, in subsystems that share no code. I only noticed they were one defect when the fourth one turned up.&lt;/p&gt;

&lt;p&gt;Here is the shape.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A check confirmed that something was present. The result was reported as a claim that it worked.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once you have the sentence, you start finding them everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalogue
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A SIEM emitter that reported success without sending anything.&lt;/strong&gt; The code fired four HTTP requests through &lt;code&gt;setImmediate&lt;/code&gt;, attached &lt;code&gt;.catch(() =&amp;gt; undefined)&lt;/code&gt; to each, slept 800ms, and printed a green check. With no SIEM configured it printed the same green check, having made no request at all. Exit 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A coverage table that said a client was logging because it once had.&lt;/strong&gt; Per-tool status read &lt;code&gt;configured ✓ logged ✓&lt;/code&gt;. The second tick came from &lt;code&gt;sessions &amp;gt; 0&lt;/code&gt; — has this client ever recorded anything. One client had stopped capturing weeks earlier. The tick stayed green the entire time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A doctor command that certified a launcher pointing nowhere.&lt;/strong&gt; It checked whether a config contained something shaped like our entry. It did. The command that entry would run lived inside a temporary directory from an old test run — the kind the operating system clears on its own schedule. Configured, said the tool. Three clients were pinned to that throwaway install when we finally looked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed-export flag that produced unsigned archives.&lt;/strong&gt; &lt;code&gt;--signed&lt;/code&gt; tried to sign, and when it couldn't — missing private key, more than one session selected — it caught the error, wrote the archive anyway, printed &lt;code&gt;signed: no&lt;/code&gt;, and exited 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verifier that passed those archives.&lt;/strong&gt; Missing signature? A warning, then exit 0. Then we fixed that, and found that a signature file containing &lt;code&gt;{}&lt;/code&gt; still passed, because the new check tested that the file could be read rather than that it contained a signature. Presence, reported as function, inside the fix for presence being reported as function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An importer that made vendor exports look like first-hand observation.&lt;/strong&gt; One import path recorded provenance metadata. The one beside it didn't. Records that came from a third-party export file were therefore indistinguishable, to every downstream reader, from sessions the tool had watched happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A summary that counted machine traffic as conversation.&lt;/strong&gt; Turns were counted by role. A tool result is stored with role &lt;code&gt;user&lt;/code&gt; — nobody typed it. A context-compaction marker is stored with role &lt;code&gt;system&lt;/code&gt; — nobody said it. The code counted anything that wasn't &lt;code&gt;user&lt;/code&gt; as AI, so both landed in the conversation totals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two implementations of one filter that disagreed.&lt;/strong&gt; The table hid reviewed items by default. The JSON output didn't. A person and a script asking the same question got different answers, and only the script's was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that travels
&lt;/h2&gt;

&lt;p&gt;These are not all equally bad, and the difference is worth naming.&lt;/p&gt;

&lt;p&gt;A false "emitted" misleads the operator standing in front of the terminal. Annoying, recoverable, contained.&lt;/p&gt;

&lt;p&gt;An unsigned archive from a &lt;code&gt;--signed&lt;/code&gt; flag &lt;strong&gt;leaves the building&lt;/strong&gt;. Someone receives a file whose name asserts that its origin can be established. They run the verifier. They get a zero. Nothing in that sequence tells them the archive proves nothing about where it came from.&lt;/p&gt;

&lt;p&gt;Severity here isn't about how wrong the claim is. It's about how far the claim travels from the person able to check it.&lt;/p&gt;

&lt;p&gt;So the fix wasn't "print a warning". It was: refuse. If &lt;code&gt;--signed&lt;/code&gt; can't sign, it fails and writes nothing at the output path — because an unsigned file sitting where a &lt;code&gt;--signed&lt;/code&gt; run was asked for is precisely what someone picks up later and trusts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one in our own pipeline
&lt;/h2&gt;

&lt;p&gt;We fixed all of that. Then we released it, and the release workflow printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Published chron-mcp@0.1.60 to npm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry 404'd for the version. For six minutes, the package did not exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm publish&lt;/code&gt; returning 0 means the registry accepted the upload. npm says so itself, in output our own log captured: &lt;em&gt;"Your package is being processed and may take a few minutes to become available."&lt;/em&gt; We printed our own success line immediately underneath it.&lt;/p&gt;

&lt;p&gt;Worse: the step that syncs our public mirror ran next, and succeeded. For those six minutes the public repository advertised code that nobody could install.&lt;/p&gt;

&lt;p&gt;Exit code, reported as availability. The same bug, in the pipeline that shipped the fixes for the same bug.&lt;/p&gt;

&lt;p&gt;It now polls the registry until the version is present, the &lt;code&gt;latest&lt;/code&gt; tag matches it, &lt;strong&gt;and&lt;/strong&gt; a fresh install from the public registry reports the right version — then prints published. Three conditions, because any one alone can be true while the release is unusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I made while writing this up
&lt;/h2&gt;

&lt;p&gt;I reported that npm had been 404ing "for about forty minutes."&lt;/p&gt;

&lt;p&gt;It was six minutes and nine seconds. I had been comparing local timestamps against UTC ones and never did the subtraction. I filed the alarm sixteen seconds before the registry converged.&lt;/p&gt;

&lt;p&gt;Forty minutes of silence from a package registry is an incident. Six is ordinary asynchronous propagation, which is what npm's output had said was happening all along. I'd spent two days removing unverified claims from a product and then made one, in the report about it, about a number I could have computed in one line.&lt;/p&gt;

&lt;p&gt;The underlying finding was real. The evidence I gave for it was not.&lt;/p&gt;

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

&lt;p&gt;Fixing twelve instances would have been worthless. What mattered was the structural changes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate the three states, and only call one of them evidence.&lt;/strong&gt; Our client diagnostics now report &lt;em&gt;configured&lt;/em&gt; (an entry exists), &lt;em&gt;loaded&lt;/em&gt; (for one client: not determinable from disk, and we say so), and &lt;em&gt;captured&lt;/em&gt; (a session from this client is in the database, with when). Only the third is evidence, and only for the moment it happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the honest state reachable.&lt;/strong&gt; Our delivery model had an &lt;code&gt;unknown&lt;/code&gt; state for "sent, no trustworthy answer". It could never actually occur, because bare &lt;code&gt;fetch&lt;/code&gt; has no timeout — a server that accepted a connection and never replied would hang forever. A state you cannot reach is not a state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let absence be the strong claim.&lt;/strong&gt; A record saying something was read is the reading system's statement about itself. It establishes very little. The &lt;em&gt;absence&lt;/em&gt; of such a record, when every read path emits reliably, is much harder to fake. Sell the negative; label the positive honestly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write refusals, not warnings.&lt;/strong&gt; Every fix above replaced a warning with a non-zero exit. A warning is a claim that the caller will probably ignore. An exit code is one they have to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tests didn't catch any of it
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most want someone else to learn from.&lt;/p&gt;

&lt;p&gt;Every layer was green independently. MCP connected. Tools available. Config exists. Database healthy. Tests passing.&lt;/p&gt;

&lt;p&gt;The tests asserted our assumptions. They declared the expected configuration — including the stale launcher format — and so preserved the defect as a specification. They checked that a config entry existed, not that the process it named could start. They verified our tool list, not that the instructions were delivered. They ran inside workspaces that happened to contain a file whose absence was the actual failure mode.&lt;/p&gt;

&lt;p&gt;The one test nobody had written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fresh client → ordinary prompt → session appears in the database → both messages recorded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else was infrastructure confirming its own existence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit we had to publish
&lt;/h2&gt;

&lt;p&gt;One more, because leaving it out would make this essay the thing it's about.&lt;/p&gt;

&lt;p&gt;Our live capture depends on a model choosing to call a tool. We can deliver the instruction reliably. We cannot make a client obey it. During testing, one client hit a budget limit and stopped before logging; one refused the call under its default permission policy; one called the wrong tool entirely.&lt;/p&gt;

&lt;p&gt;So the release ships saying so: capture is best-effort, not mandatory, and the diagnostics say &lt;em&gt;configured&lt;/em&gt; where they used to say &lt;em&gt;logging works&lt;/em&gt;. Enforcement needs an execution path we control — a wrapper that opens the audit session before the client starts and holds the output until the write lands — and we haven't built it.&lt;/p&gt;

&lt;p&gt;Shipping a tool that declines to flatter its own integration is uncomfortable. It is less uncomfortable than the alternative, which is a green check mark that means nothing, in a product sold on its records being trustworthy.&lt;/p&gt;

&lt;p&gt;If you build anything that reports on its own health, it's worth an afternoon with one question: &lt;strong&gt;for each green mark in your output, what did you actually observe, and when?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I expect you'll find at least one that answers "I observed that a thing exists, and I'm telling you it works."&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>devops</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>One NUL byte made 14,994 signed records verify as tampered</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Sat, 26 Sep 2026 19:06:42 +0000</pubDate>
      <link>https://dev.to/sirinivask/one-nul-byte-made-14994-signed-records-verify-as-tampered-3ih0</link>
      <guid>https://dev.to/sirinivask/one-nul-byte-made-14994-signed-records-verify-as-tampered-3ih0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27vzb4ocslp04izp7lj1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F27vzb4ocslp04izp7lj1.png" alt="Chron" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;Chron&lt;/a&gt;, a local audit log for AI coding sessions. Every message is hashed into a chain, so if a row is edited after the fact, verification fails and says which row.&lt;/p&gt;

&lt;p&gt;Last week I added a path that imports existing Claude Code transcripts. I ran it against my own machine, 33 transcripts, about 70,000 events — and then, instead of spot-checking one session, verified all of them. The biggest one failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verifying 3847fff0 — 14,994 messages
Hash chain
  ✗ Row 8842: content_hash mismatch — row tampered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing had tampered with anything. The rows were minutes old and nothing but my own import had ever written them.&lt;/p&gt;

&lt;p&gt;For a tool whose entire job is telling you whether a record was altered, a false positive is worse than a missed detection. A missed detection is a gap. A false positive teaches people to ignore the alarm.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I assumed, and what it was
&lt;/h2&gt;

&lt;p&gt;My first guess was ordering. Imported transcripts keep the client's line order rather than timestamp order, because 11,591 lines in my corpus carry a timestamp earlier than a line before them. Get that wrong and the chain links up in the wrong sequence.&lt;/p&gt;

&lt;p&gt;That guess was wrong, and the error message said so. A &lt;code&gt;prev_hash&lt;/code&gt; mismatch means the rows are in the wrong order. A &lt;code&gt;content_hash&lt;/code&gt; mismatch means this row's stored hash doesn't match this row's stored fields. Ordering was fine. The row itself disagreed with itself.&lt;/p&gt;

&lt;p&gt;Which shouldn't be possible. The code hashes a string and inserts the same string, in the same function, a few lines apart.&lt;/p&gt;

&lt;p&gt;So I looked at what was actually in the row:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&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;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;blob&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;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;instr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nul_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2d9746d3…'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chars  bytes  nul_at
298    530    299
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;298 characters. 530 bytes. And a NUL at position 299 — past the end of a string that is only 298 long.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;SQLite stored every one of those 530 bytes. It is perfectly happy to hold a NUL inside a TEXT value. What it will not do is pretend the NUL isn't there when something asks for the value as a string. &lt;code&gt;length()&lt;/code&gt; stops at the NUL. So does the driver, handing the value back to JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wrote:  530 bytes
read:   298 characters, 308 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the write hashed 530 bytes of content and the verification hashed the 298 characters that came back. Two different inputs, two different digests, one "row tampered".&lt;/p&gt;

&lt;p&gt;The content was tool output. Some program in some session printed a NUL byte, the way programs sometimes do, and it rode all the way into an evidence store. One byte, in one row, out of 73,526 — enough to invalidate a 14,994-event chain, because every row after it inherits the break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that doesn't work
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to strip the NUL inside the hash function. It fails, and it's worth seeing why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write:&lt;/strong&gt; hash &lt;code&gt;sanitize(content)&lt;/code&gt;, store &lt;code&gt;content&lt;/code&gt; — still 530 bytes, still with the NUL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify:&lt;/strong&gt; read back the truncated 298 characters, hash &lt;code&gt;sanitize(truncated)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;sanitize&lt;/code&gt; doesn't help, because the two sides aren't hashing the same content in the first place. The stored value is the problem. You have to change what goes into the database, not just what goes into the digest.&lt;/p&gt;

&lt;p&gt;The rule I ended up writing on the wall: &lt;strong&gt;hash what you can read back.&lt;/strong&gt; Not what you were handed — what the storage layer will give you again later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out whether your stack does this
&lt;/h2&gt;

&lt;p&gt;Rather than fix the one byte I'd found and move on, I went looking for what else wouldn't survive the trip. This is short enough to run against your own database:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@libsql/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file::memory:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&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;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATE TABLE t (id TEXT PRIMARY KEY, body TEXT)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;embedded NUL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="s2"&gt;`before&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;after`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lone high surrogate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`before&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xd800&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;after`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lone low surrogate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;`before&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xdc00&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;after`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emoji (valid pair)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;before&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u{1f600}after&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CRLF and tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;before&lt;/span&gt;&lt;span class="se"&gt;\r\n\t&lt;/span&gt;&lt;span class="s1"&gt;after&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ESC and DEL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="s2"&gt;`before&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1b&lt;/span&gt;&lt;span class="p"&gt;)}${&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x7f&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;after`&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrote&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&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;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO t VALUES (?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrote&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT body FROM t WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;wrote&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok    &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BROKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`len &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wrote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  sha &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wrote&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;@libsql/client&lt;/code&gt; 0.17.3 and Node 23:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BROKEN embedded NUL         len 12 -&amp;gt;  6  sha 92e7bd379d66 -&amp;gt; 6db7d803e74f
BROKEN lone high surrogate  len 12 -&amp;gt; 12  sha a95d623c5792 -&amp;gt; a95d623c5792
BROKEN lone low surrogate   len 12 -&amp;gt; 12  sha a95d623c5792 -&amp;gt; a95d623c5792
ok     emoji (valid pair)   len 13 -&amp;gt; 13  sha cd9b426a0837 -&amp;gt; cd9b426a0837
ok     CRLF and tab         len 14 -&amp;gt; 14  sha 51daa3413452 -&amp;gt; 51daa3413452
ok     ESC and DEL          len 13 -&amp;gt; 13  sha 9cc6db066164 -&amp;gt; 9cc6db066164
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else I threw at it survives. Control characters are fine. CRLF is fine. Emoji are fine, as long as the surrogate pair is intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The near-miss in that output
&lt;/h2&gt;

&lt;p&gt;Look at the two surrogate rows again. &lt;code&gt;read === wrote&lt;/code&gt; is false — the string that came back is not the one that went in. But the hashes match.&lt;/p&gt;

&lt;p&gt;I nearly wrote this up as "NUL and lone surrogates both break the chain." They don't, and the reason is more interesting than if they did.&lt;/p&gt;

&lt;p&gt;A lone surrogate is not encodable as UTF-8. The driver substitutes U+FFFD on the way through. And Node's UTF-8 encoder performs the &lt;em&gt;identical&lt;/em&gt; substitution when you hash the string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;uD800b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;61&lt;/span&gt; &lt;span class="nx"&gt;ef&lt;/span&gt; &lt;span class="nx"&gt;bf&lt;/span&gt; &lt;span class="nx"&gt;bd&lt;/span&gt; &lt;span class="mi"&gt;62&lt;/span&gt;
&lt;span class="nx"&gt;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a�b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;61&lt;/span&gt; &lt;span class="nx"&gt;ef&lt;/span&gt; &lt;span class="nx"&gt;bf&lt;/span&gt; &lt;span class="nx"&gt;bd&lt;/span&gt; &lt;span class="mi"&gt;62&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sides mangle it the same way, so the digests agree and verification passes. The integrity check survives on a coincidence, that two unrelated components happen to implement the same replacement rule.&lt;/p&gt;

&lt;p&gt;Nothing documents that. Nothing enforces it. Hash bytes instead of a string, move to a runtime whose encoder throws instead of substituting, and the coincidence stops holding. A load-bearing accident is still an accident.&lt;/p&gt;

&lt;p&gt;So I normalise both, for different reasons. The NUL, because it breaks verification today. The surrogate, because storing content the caller never sent is bad on its own terms, and because I would rather not depend on two encoders agreeing by luck.&lt;/p&gt;

&lt;p&gt;Both become U+FFFD rather than being deleted. That's the standard marker for a character that couldn't be represented, and it keeps the fact that something was there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the test would have caught it
&lt;/h2&gt;

&lt;p&gt;Writing a test after fixing a bug tells you nothing until you've watched it fail. I reverted the normalisation and ran the new tests against the old behaviour: 11 of 21 failed, including verification through every write path and a twelve-row chain. Put the fix back, all 21 pass.&lt;/p&gt;

&lt;p&gt;That's the part I'd skip if I were in a hurry, and it's the part that tells you the test is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hash what you can read back.&lt;/strong&gt; Any scheme that hashes content and stores it separately has a serialisation boundary in the middle, and the boundary is allowed to change your data. Content-addressed stores, signed records, dedup keys, ETags - same shape, same exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A silent substitution is worse than an error.&lt;/strong&gt; Every layer here behaved reasonably on its own. SQLite stored the bytes. The driver returned a C string. Node's encoder replaced what it couldn't encode. Nothing logged a warning, and the defect surfaced as an accusation against innocent data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify all of it, once.&lt;/strong&gt; This was one row in 73,526. Any sampling strategy misses it. The only reason I found it before a user did is that I got suspicious and checked everything instead of the one session I'd picked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And be careful which failures you claim.&lt;/strong&gt; I was one draft away from publishing a confident, wrong explanation of the surrogate case. The probe was right there; I just hadn't read its output closely enough.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>node</category>
    </item>
    <item>
      <title>What Evidence Does an AI Agent Actually Need to Leave Behind?</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:28:39 +0000</pubDate>
      <link>https://dev.to/sirinivask/what-evidence-does-an-ai-agent-actually-need-to-leave-behind-2b02</link>
      <guid>https://dev.to/sirinivask/what-evidence-does-an-ai-agent-actually-need-to-leave-behind-2b02</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupd2191ms56pg9e75yh1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupd2191ms56pg9e75yh1.png" alt="Chron" width="799" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Evidence Does an AI Agent Actually Need to Leave Behind?
&lt;/h2&gt;

&lt;p&gt;There is a question that keeps surfacing as AI agents move from answering questions in chat to actually doing things, writing code, modifying configs, calling APIs, deploying services.&lt;/p&gt;

&lt;p&gt;The question is not "how do we stop them from acting?"&lt;/p&gt;

&lt;p&gt;The question is: &lt;strong&gt;what evidence do we need after they act?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Git Commit Problem
&lt;/h2&gt;

&lt;p&gt;The reflex answer is: just look at the diff.&lt;/p&gt;

&lt;p&gt;A Git commit shows you what changed. That used to be enough when a human sat behind every change. The human held the context. You could ask them. The commit message, however brief, was a pointer to a person who understood the decision.&lt;/p&gt;

&lt;p&gt;With an AI agent, the commit is often the &lt;em&gt;only&lt;/em&gt; artifact. And it hides almost everything that mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What did the user actually ask for?&lt;/li&gt;
&lt;li&gt;What did the agent read before deciding what to change?&lt;/li&gt;
&lt;li&gt;Which tools did it call, and in what order?&lt;/li&gt;
&lt;li&gt;Did it touch secrets, authentication tokens, environment variables, or production config along the way?&lt;/li&gt;
&lt;li&gt;Was there a human in the loop, and if so, did they actually review anything, or just click Allow?&lt;/li&gt;
&lt;li&gt;Can someone reconstruct the full sequence six months from now when something breaks in production?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A commit hash answers none of those questions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Is the Practical Governance Problem Right Now
&lt;/h2&gt;

&lt;p&gt;We have spent years building approval workflows for &lt;em&gt;human&lt;/em&gt; code changes. Pull requests, code review, change advisory boards, ticket numbers in commit messages. These processes assume a human author who can be questioned, who made a conscious decision, who can explain their reasoning.&lt;/p&gt;

&lt;p&gt;AI agents break that assumption quietly.&lt;/p&gt;

&lt;p&gt;The agent acts faster than review cycles were designed for. It calls ten tools in thirty seconds. It reads a config file, writes a new one, runs a test, opens a PR, and waits, all before anyone noticed it started. The change looks reasonable. The tests pass. The PR gets merged.&lt;/p&gt;

&lt;p&gt;Three months later, something unexpected happens in production. You need to understand what the agent knew, what it read, what choices it made, and what a human approved.&lt;/p&gt;

&lt;p&gt;If the only record is the commit, you have nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Minimum Viable Evidence Set
&lt;/h2&gt;

&lt;p&gt;I am not arguing for heavyweight approval workflows on every AI action. That path leads to agents being so throttled they are useless.&lt;/p&gt;

&lt;p&gt;But I do think every &lt;em&gt;meaningful&lt;/em&gt; AI action, anything that modifies state outside the agent's own scratchpad, should leave enough evidence to answer questions when it matters.&lt;/p&gt;

&lt;p&gt;Here is what that looks like in practice:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The original intent
&lt;/h3&gt;

&lt;p&gt;What did the user ask for? Not the agent's interpretation, the actual message. This is the anchor for everything else. If the change cannot be traced back to a human request, that is itself meaningful information.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The tool call sequence
&lt;/h3&gt;

&lt;p&gt;Which tools did the agent call, in what order, and what did it pass to them? This is the chain of decisions. A file read that preceded a file write tells you the agent saw the old value before replacing it. A secrets lookup before a config change is a different kind of event than a blind write.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. What the agent read
&lt;/h3&gt;

&lt;p&gt;Before an agent modifies something, it usually reads something. That read context is often the difference between "the agent made a reasonable change given what it saw" and "the agent made a change that only makes sense if it misread something."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Sensitive surface contact
&lt;/h3&gt;

&lt;p&gt;Did the interaction touch authentication, authorization, secrets, infrastructure definitions, or production configuration? These are not just higher-risk changes, they are the category of changes that auditors, security teams, and incident responders will ask about first.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Human approval record
&lt;/h3&gt;

&lt;p&gt;Was a human shown anything before the change happened? Did they click through a permission prompt, review a plan, or simply let the agent run autonomously? The presence or absence of human review is material. So is the &lt;em&gt;kind&lt;/em&gt; of review, "I saw a one-line summary" is different from "I read the full plan and the diff."&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Reconstructability
&lt;/h3&gt;

&lt;p&gt;Can someone who was not in the room replay the sequence? Not re-run the agent, but follow the evidence trail and understand what happened. This is the test. If the answer is no, the evidence is insufficient.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where People Draw the Line
&lt;/h2&gt;

&lt;p&gt;In practice, teams end up in one of a few places:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git history only.&lt;/strong&gt; Fast, familiar, already required. Works fine when agents are doing what a junior developer would do and the stakes are low. Fails when something goes wrong and you need to explain the decision chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool-call logs.&lt;/strong&gt; A step up. You know what the agent touched. You do not always know why, or what it read, or what the user originally asked for. Better for incident response than for governance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval records.&lt;/strong&gt; Some teams require a human sign-off checkpoint before the agent can modify certain surfaces — production, secrets, auth config. This is reasonable for high-stakes surfaces. It does not help you reconstruct what happened before the approval, or what the agent read to arrive at its proposal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full session traces.&lt;/strong&gt; The full conversation, user messages, tool calls, reads, writes, tool results, stored with tamper-evident hashes. This is the complete evidence set. It is also the most expensive to store and the most sensitive to handle, because it may contain secrets that appeared in tool results.&lt;/p&gt;

&lt;p&gt;The honest answer is that the right level depends on the surface. An agent writing unit tests probably does not need a full session trace. An agent modifying IAM policies probably does.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Chron Is Doing Here
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;Chron&lt;/a&gt; is an MCP server that creates tamper-evident audit logs of AI sessions — stored locally, on your own machine, in a SQLite database you control.&lt;/p&gt;

&lt;p&gt;Every tool call is logged with a cryptographic hash chain. The user's original message is captured. The sequence is reconstructable. The database does not leave your environment.&lt;/p&gt;

&lt;p&gt;It crossed 9,000 npm downloads this week, which is a signal that the question this article is asking is not just theoretical. People are encountering it in real work and looking for answers.&lt;/p&gt;

&lt;p&gt;Chron's position is deliberately minimal: it records, it does not certify. It does not tell you whether a change was authorized or correct. It gives you the evidence to answer those questions yourself, or to hand to someone else who needs to answer them later.&lt;/p&gt;

&lt;p&gt;That boundary - &lt;strong&gt;recording vs. certifying&lt;/strong&gt; - matters. The tool that captures evidence should not be the same tool that decides what the evidence means. Those are different jobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Question Worth Asking Your Team
&lt;/h2&gt;

&lt;p&gt;If an AI agent in your environment made a change right now, something it wrote, deployed, or modified, and three months from now you needed to explain that change to a customer, an auditor, or your own security team, what would you show them?&lt;/p&gt;

&lt;p&gt;If the answer is "the commit," that is worth thinking about.&lt;/p&gt;

&lt;p&gt;Not because commits are bad. Because they were designed for a world where a human sat behind every change, and that world is changing faster than our evidence practices are.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Chron is available as an MCP server: &lt;code&gt;npx chron-mcp&lt;/code&gt;. It works with Claude, Cursor, and any MCP-compatible AI tool. The audit database is yours - local SQLite, no cloud egress.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aigovernance</category>
      <category>agenticai</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>The AI Race Has a Missing Question: Can We Explain What Our Agents Already Did?</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Fri, 18 Sep 2026 16:08:34 +0000</pubDate>
      <link>https://dev.to/sirinivask/the-ai-race-has-a-missing-question-can-we-explain-what-our-agents-already-did-4l03</link>
      <guid>https://dev.to/sirinivask/the-ai-race-has-a-missing-question-can-we-explain-what-our-agents-already-did-4l03</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqly1fv0ca4jc4l6mp6m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjqly1fv0ca4jc4l6mp6m.png" alt="Chron" width="799" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The public conversation around advanced AI often collapses into two positions.&lt;/p&gt;

&lt;p&gt;One side says we should slow down.&lt;/p&gt;

&lt;p&gt;The other says progress is inevitable, so we should accelerate and compete.&lt;/p&gt;

&lt;p&gt;Both arguments matter. But teams deploying AI agents today face a more immediate problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can we explain what the agents already did inside our own environments?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question is less dramatic than the future of superintelligence. It is also more operationally urgent.&lt;/p&gt;

&lt;p&gt;AI coding assistants and autonomous agents are no longer limited to suggesting text in a chat window. They can inspect repositories, execute commands, call tools, modify files, use credentials, interact with ticketing systems, and reach external services.&lt;/p&gt;

&lt;p&gt;Once that happens, "we have a policy" is no longer enough.&lt;/p&gt;

&lt;p&gt;A team needs to be able to answer basic questions after an incident, review, or audit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What was the original request?&lt;/li&gt;
&lt;li&gt;Which tools did the agent use?&lt;/li&gt;
&lt;li&gt;What happened between the prompt and the final answer?&lt;/li&gt;
&lt;li&gt;Which files or systems were changed?&lt;/li&gt;
&lt;li&gt;Was a risky action approved?&lt;/li&gt;
&lt;li&gt;Did a person review the result?&lt;/li&gt;
&lt;li&gt;Can someone else reconstruct the sequence later?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important word is not logging. It is &lt;strong&gt;causality&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A repository might show that a file changed. A chat transcript might show that someone asked for help. An access log might show that a credential was used.&lt;/p&gt;

&lt;p&gt;But those records often do not explain how the events connect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which agent action led to this command, which led to this file write, under which user request, with which approval?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That missing chain is where accountability disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not an argument for blocking every action
&lt;/h2&gt;

&lt;p&gt;Human checkpoints matter, especially for irreversible or high-impact actions. But prompting a person for approval on every operation creates its own failure mode: approval fatigue.&lt;/p&gt;

&lt;p&gt;Anthropic has written publicly about this tradeoff. Its experience with agent permissions found that people approved the overwhelming majority of prompts, making constant confirmation an unreliable safety mechanism by itself. Their focus is increasingly on technical containment: constraining what an agent can do through boundaries such as sandboxes, virtual machines, and egress controls. Read &lt;a href="https://www.anthropic.com/engineering/how-we-contain-claude" rel="noopener noreferrer"&gt;How we contain Claude across products&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The point is not to eliminate human review.&lt;/p&gt;

&lt;p&gt;It is to reserve it for the moments where it matters, then preserve evidence that the review happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance needs two layers
&lt;/h2&gt;

&lt;p&gt;A useful operating model has two distinct layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preventive controls&lt;/strong&gt; constrain what an agent can do: scoped credentials, network boundaries, sandboxes, policy enforcement, and approval gates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detective controls&lt;/strong&gt; preserve what the agent did: requests, tool calls, results, files changed, timestamps, decisions, approvals, and evidence of review.&lt;/p&gt;

&lt;p&gt;One without the other is incomplete.&lt;/p&gt;

&lt;p&gt;Preventive controls can reduce harm, but they cannot explain an action later unless the decision itself is recorded.&lt;/p&gt;

&lt;p&gt;Logs can support an investigation, but they cannot prevent an agent from reaching a system it should never have been allowed to access.&lt;/p&gt;

&lt;p&gt;This is why the conversation should not be "governance versus innovation." Good governance is part of what lets teams adopt useful agents with confidence.&lt;/p&gt;

&lt;p&gt;OpenAI's own discussion of safely operating coding agents describes the same ingredients: technical boundaries, approval decisions, agent-aware telemetry, and the ability to inspect the original request, tool activity, results, and policy decisions when something needs review. Read &lt;a href="https://openai.com/index/running-codex-safely" rel="noopener noreferrer"&gt;Running Codex safely at OpenAI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the evidence trail before you need it
&lt;/h2&gt;

&lt;p&gt;The worst time to ask how an AI agent acted is after something went wrong.&lt;/p&gt;

&lt;p&gt;By then, the prompt may be gone, the context may have been compacted, tool output may have changed, and the person who ran the session may not remember why a decision was made.&lt;/p&gt;

&lt;p&gt;Evidence is cheap to preserve at the moment of action and expensive to reconstruct later.&lt;/p&gt;

&lt;p&gt;That is the problem Chron is built around.&lt;/p&gt;

&lt;p&gt;Chron is a local-first audit trail for AI-assisted work. It records AI sessions and their events, maintains evidence integrity locally, and helps teams find the sessions that warrant review. It does not certify compliance or replace a human auditor. It gives teams a more defensible starting point: a record of what occurred.&lt;/p&gt;

&lt;p&gt;The argument about how quickly AI should progress will continue.&lt;/p&gt;

&lt;p&gt;In the meantime, organizations should make sure that the AI already acting in their codebases and environments is not operating in a black box.&lt;/p&gt;

&lt;p&gt;Because the question after an incident will not be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Was the model powerful?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It will be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What did it do?"&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Chron has passed 8,852 downloads. It is still early, local-first, and free to try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ai</category>
      <category>aigovernance</category>
      <category>devsecops</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The AI Race Is a Governance Problem Before It Is a Superintelligence Problem</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:13:56 +0000</pubDate>
      <link>https://dev.to/sirinivask/the-ai-race-is-a-governance-problem-before-it-is-a-superintelligence-problem-2g7i</link>
      <guid>https://dev.to/sirinivask/the-ai-race-is-a-governance-problem-before-it-is-a-superintelligence-problem-2g7i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsmzclm1f0qv2wi90y5bd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsmzclm1f0qv2wi90y5bd.png" alt="Chron" width="799" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Long-Term Debate Is Already an Operational Problem
&lt;/h2&gt;

&lt;p&gt;This week, a researcher who said he spent three years working at OpenAI and Anthropic resigned publicly and warned that leading labs are moving too quickly toward systems that may become difficult to control.&lt;/p&gt;

&lt;p&gt;His warning is his own assessment, not a prediction anyone can prove today. But it raises a question that engineering teams should not wait to answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What evidence do we have of what our AI systems are actually doing?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  From Answering Questions to Taking Actions
&lt;/h2&gt;

&lt;p&gt;The long-term debate is about increasingly capable and autonomous systems.&lt;/p&gt;

&lt;p&gt;The immediate problem is simpler.&lt;/p&gt;

&lt;p&gt;AI tools already inspect repositories, execute commands, call tools, change files, and influence work in real environments. They are moving from "answering questions" to "taking actions."&lt;/p&gt;

&lt;p&gt;When that happens, a prompt and a final answer are not enough.&lt;/p&gt;

&lt;p&gt;The meaningful security and governance surface is everything in between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the files an AI inspected&lt;/li&gt;
&lt;li&gt;the tools it invoked&lt;/li&gt;
&lt;li&gt;the commands it ran&lt;/li&gt;
&lt;li&gt;the code it changed&lt;/li&gt;
&lt;li&gt;the secrets it encountered&lt;/li&gt;
&lt;li&gt;the actions a human reviewed or approved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not merely application logging. It is operational evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap: There Is No Default Evidence Trail
&lt;/h2&gt;

&lt;p&gt;Most teams have some combination of chat history, source control, CI logs, and cloud audit logs.&lt;/p&gt;

&lt;p&gt;Those are useful, but they are disconnected. They rarely answer one important question cleanly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;What did this AI-assisted task do, why did it do it, and can another person review the path afterward?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That gap matters today, even if you have no view on the most extreme forecasts about AI.&lt;/p&gt;

&lt;p&gt;Organizations do not need to wait for hypothetical superintelligence before they make AI actions visible and reviewable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What an Honest Evidence Trail Looks Like
&lt;/h2&gt;

&lt;p&gt;A trustworthy record needs to be explicit about its boundaries.&lt;/p&gt;

&lt;p&gt;If a tool did not observe part of a conversation, it should not recreate it from memory or ask the model to estimate what happened. That would turn self-report into evidence.&lt;/p&gt;

&lt;p&gt;Chron v0.1.56 handles this with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron attach codex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command begins recording an existing conversation from the current point onward. It marks the session as partial evidence and keeps the boundary visible in verification and coverage reports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chron verify &amp;lt;session-id&amp;gt;
chron coverage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message is deliberately plain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Chron began recording after this conversation had already started. Earlier turns are outside the evidence boundary.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is better than a complete-looking record with a hidden gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Chron Is and Is Not
&lt;/h2&gt;

&lt;p&gt;Chron is a local-first audit evidence layer for AI-assisted work. When configured with supported AI tools, it records messages, tool activity, code changes, secret detections, timestamps, and tamper-evident hashes.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; an alignment solution.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; a compliance certification.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; a substitute for human oversight.&lt;/p&gt;

&lt;p&gt;It is one practical control: making AI-assisted actions inspectable after they happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  Accountability Has to Start Somewhere
&lt;/h2&gt;

&lt;p&gt;The AI race may be a global problem. But accountable evidence is something every engineering team can start building now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chron is local-first and free to try. The public warnings from AI researchers are worth reading alongside the more practical lessons from real evaluation incidents — they are two sides of the same problem.&lt;/p&gt;

&lt;p&gt;As AI gets more capable, "trust us" will not be enough. We need evidence.&lt;/p&gt;

</description>
      <category>aigovernance</category>
      <category>security</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>My AI audit tool was merging Claude and Cursor sessions. The bug was one UNIQUE constraint.</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:12:25 +0000</pubDate>
      <link>https://dev.to/sirinivask/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique-constraint-5amc</link>
      <guid>https://dev.to/sirinivask/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique-constraint-5amc</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://www.npmjs.com/package/chron-mcp" rel="noopener noreferrer"&gt;Chron&lt;/a&gt;, an MCP server that writes an audit log of AI coding sessions to a local SQLite database. Every message gets a timestamp, and every session records which tool produced it: Claude, Cursor, Codex.&lt;/p&gt;

&lt;p&gt;A while back I opened the history and found Claude messages and Cursor messages interleaved inside a single session. Two different tools, two different terminals, one session record.&lt;/p&gt;

&lt;p&gt;For a tool whose entire job is attribution, that is about as bad as a bug gets.&lt;/p&gt;

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

&lt;p&gt;The sessions table looked like this:&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;sessions&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;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;title&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="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ai_tool&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&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;updated_at&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a tool starts work it calls &lt;code&gt;init_session&lt;/code&gt; with a title and its own &lt;code&gt;ai_tool&lt;/code&gt;. If a session with that title already exists, resume it. Otherwise create a new one.&lt;/p&gt;

&lt;p&gt;Both Claude and Cursor generate a short descriptive title from the task. Working in the same repo on the same task, they generate the same title. Something like &lt;code&gt;General assistance session&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UNIQUE(title)&lt;/code&gt; then guarantees only one row can exist for that title. So the second tool does not get its own session. It resumes the first one. Claude's messages and Cursor's messages land under the same session id, and &lt;code&gt;ai_tool&lt;/code&gt; on that session reports whichever tool happened to start first.&lt;/p&gt;

&lt;p&gt;The constraint was doing exactly what it was written to do. It was just the wrong constraint. A title was never the identity of a session. The pair &lt;code&gt;(title, tool)&lt;/code&gt; was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix attempt one, which does not work
&lt;/h2&gt;

&lt;p&gt;The obvious move:&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_sessions_title_tool&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ai_tool&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 wrong, and it is wrong in a way that survives a casual test.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ai_tool&lt;/code&gt; is nullable. In SQL, &lt;code&gt;NULL&lt;/code&gt; is never equal to &lt;code&gt;NULL&lt;/code&gt;, and that includes comparisons inside a unique index. The constraint stops constraining the moment the column is NULL:&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;a&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&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;ai_tool&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;ia&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ai_tool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- succeeds&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two identical rows under a unique index. This is standard SQL behaviour rather than a SQLite quirk, but it is easy to forget the moment you add a nullable column to a composite key.&lt;/p&gt;

&lt;p&gt;It mattered here because Chron has legitimate NULL &lt;code&gt;ai_tool&lt;/code&gt; rows: sessions created through the library API without a tool set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Index an expression instead of the raw column:&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_sessions_title_tool&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai_tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NULL collapses to the empty string, which does compare equal to itself:&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;b&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&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;ai_tool&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;ib&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai_tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;-- UNIQUE constraint failed&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'claude'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- ok&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cursor'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per tool, duplicates within a tool still rejected. That is the property I actually wanted.&lt;/p&gt;

&lt;p&gt;The lookup code had to match, of course. Resume was searching by title alone, so even with the right constraint it would have kept finding the other tool's row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&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;sessions&lt;/span&gt;&lt;span class="p"&gt;)&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;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&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;sessions&lt;/span&gt;&lt;span class="p"&gt;)&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;and&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;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;requestedTool&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_tool&lt;/span&gt;&lt;span class="p"&gt;)&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;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestedTool&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;A constraint and the query that relies on it are one unit. Changing only one of them just moves the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dropping a constraint in SQLite
&lt;/h2&gt;

&lt;p&gt;SQLite has no &lt;code&gt;ALTER TABLE ... DROP CONSTRAINT&lt;/code&gt;. The inline &lt;code&gt;UNIQUE&lt;/code&gt; on &lt;code&gt;title&lt;/code&gt; is part of the table definition, so removing it means rebuilding the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PRAGMA foreign_keys = OFF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="s2"&gt;`CREATE TABLE sessions_migration (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,     -- no UNIQUE
    ai_tool TEXT,
    ...
  )`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO sessions_migration SELECT id,title,ai_tool,... FROM sessions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DROP TABLE sessions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALTER TABLE sessions_migration RENAME TO sessions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ignore */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PRAGMA foreign_keys = ON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Standard shadow-table dance. Nothing surprising.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration bug that was worse than the original bug
&lt;/h2&gt;

&lt;p&gt;Here is the part worth the post.&lt;/p&gt;

&lt;p&gt;I first wrote the migration guard as: &lt;em&gt;if the composite index does not exist, migrate.&lt;/em&gt; It reads perfectly sensibly, and it fails on exactly the databases that need it.&lt;/p&gt;

&lt;p&gt;The schema bootstrap runs &lt;code&gt;CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_tool ...&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; the migration check. On an existing database that still carries the legacy inline &lt;code&gt;UNIQUE(title)&lt;/code&gt;, that statement succeeds. It creates the composite index on the old table. The old constraint is still sitting there, untouched.&lt;/p&gt;

&lt;p&gt;So by the time the migration check runs, the index exists, the guard concludes "already migrated", and it skips. The table keeps &lt;code&gt;UNIQUE(title)&lt;/code&gt; forever.&lt;/p&gt;

&lt;p&gt;Fresh databases were fine, because they were created correctly from the start. The test suite passed, because tests build fresh databases. Every real user upgrading from an older version would have kept the bug.&lt;/p&gt;

&lt;p&gt;The guard has to ask about the constraint, not about a side effect that usually correlates with it. The only way to see the constraint is the stored DDL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&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;client&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tableSql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasLegacyTitleUnique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;title&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+TEXT&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+NOT&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+NULL&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+UNIQUE&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tableSql&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="nx"&gt;hasLegacyTitleUnique&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;compositeIdxMissing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// rebuild&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regex against DDL is not elegant. It is, however, the thing that is actually true.&lt;/p&gt;

&lt;p&gt;And the test has to start from the old schema, not a fresh one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build a legacy database on purpose&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="s2"&gt;`CREATE TABLE sessions (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL UNIQUE,   -- the old constraint
  ai_tool TEXT, ...
)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="s2"&gt;`INSERT INTO sessions VALUES ('old-claude', 'General assistance session', 'claude', ...)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dbPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&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;client&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;title&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+TEXT&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+NOT&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+NULL&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+UNIQUE&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// cursor can now hold the same title&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="s2"&gt;`INSERT INTO sessions VALUES ('new-cursor', 'General assistance session', 'cursor', ...)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// but a duplicate within the same tool is still rejected&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&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="s2"&gt;`INSERT INTO sessions VALUES ('dupe-claude', 'General assistance session', 'claude', ...)`&lt;/span&gt;
&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I took from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A UNIQUE constraint is an identity claim.&lt;/strong&gt; &lt;code&gt;UNIQUE(title)&lt;/code&gt; asserted "a title identifies a session". That was never true, and the database enforced the false claim faithfully until the day two tools showed up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nullable columns in composite unique indexes usually do not do what you want.&lt;/strong&gt; If the column can be NULL, index an expression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migration guards should test for the thing you are fixing.&lt;/strong&gt; Not for a marker that normally accompanies it. Ordering inside your own bootstrap can invalidate the marker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A migration test that starts from a fresh schema tests nothing.&lt;/strong&gt; Construct the old schema, put a row in it, then migrate. This is the one that nearly got me: the fix was correct, the suite was green, and real upgrades would have stayed broken.&lt;/p&gt;

&lt;p&gt;That last point is the general shape of the lesson. Green tests told me the bug was fixed. They were testing a database that never had the bug.&lt;/p&gt;




&lt;p&gt;Chron is on npm as &lt;code&gt;chron-mcp&lt;/code&gt; if you want to look at the code, or run &lt;code&gt;npx chron-mcp&lt;/code&gt; to point it at your own AI sessions.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>debugging</category>
      <category>database</category>
      <category>ai</category>
    </item>
    <item>
      <title>npm Trusted Publishing fails with a 404, and the 404 is lying to you</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Sun, 06 Sep 2026 14:43:49 +0000</pubDate>
      <link>https://dev.to/sirinivask/npm-trusted-publishing-fails-with-a-404-and-the-404-is-lying-to-you-3b7i</link>
      <guid>https://dev.to/sirinivask/npm-trusted-publishing-fails-with-a-404-and-the-404-is-lying-to-you-3b7i</guid>
      <description>&lt;p&gt;npm is winding down 2FA-bypass granular access tokens. Since &lt;a href="https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/" rel="noopener noreferrer"&gt;31 July 2026&lt;/a&gt; they can no longer perform account, organisation, or package management operations, which includes editing trusted publishing configuration. In January 2027 they lose direct publish as well, leaving them able to read private packages and stage a publish that a maintainer then approves with 2FA.&lt;/p&gt;

&lt;p&gt;The replacement is OIDC Trusted Publishing, where you register a GitHub repo and workflow on npmjs.com and the workflow authenticates with a short-lived token instead of a stored secret.&lt;/p&gt;

&lt;p&gt;I migrated. The release workflow then failed four times in a row, and the error message pointed nowhere near the actual problem.&lt;/p&gt;

&lt;p&gt;This is the writeup I wanted to find while debugging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm notice publish Signed provenance statement with source and
  build information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
  https://search.sigstore.dev/?logIndex=2685306213
npm error code E404
npm error 404 Not Found - PUT https://registry.npmjs.org/chron-mcp - Not found
npm error 404  'chron-mcp@0.1.55' is not in this registry.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package exists. It has published versions. I own it. And the registry says it is not there.&lt;/p&gt;

&lt;p&gt;Two things in that output are actively misleading, and I chased both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red herring one: the provenance step succeeds
&lt;/h2&gt;

&lt;p&gt;Look at the order. Provenance signing works. It reaches sigstore, signs, and posts to the public transparency log. That requires the GitHub Actions OIDC token, so &lt;code&gt;id-token: write&lt;/code&gt; is clearly working.&lt;/p&gt;

&lt;p&gt;It is very natural to conclude that OIDC is fine and the problem is something else.&lt;/p&gt;

&lt;p&gt;It is not the same feature. Provenance signing uses the OIDC id-token to sign an attestation with sigstore. Trusted Publishing uses the OIDC id-token to exchange for a registry auth token. Different mechanisms, different code paths, introduced in different npm versions. One can work perfectly while the other does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red herring two: a token that looks leaked
&lt;/h2&gt;

&lt;p&gt;The step environment shows this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NPM_CONFIG_USERCONFIG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/home/runner/work/_temp/.npmrc&lt;/span&gt;
  &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;XXXXX-XXXXX-XXXXX-XXXXX&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks like a masked secret. My first theory was that a stale &lt;code&gt;NPM_TOKEN&lt;/code&gt; was being injected and overriding OIDC, so I removed it from the workflow and added &lt;code&gt;unset NODE_AUTH_TOKEN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It made no difference, because that value is not a secret at all. GitHub is not masking anything. Those are real X characters.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;actions/setup-node@v4&lt;/code&gt; writes an &lt;code&gt;.npmrc&lt;/code&gt; containing &lt;code&gt;//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}&lt;/code&gt;, and then exports a dummy value when you have not supplied one, so that npm does not warn about a missing variable. From &lt;code&gt;src/authutil.ts&lt;/code&gt; on the &lt;code&gt;v4&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Export empty node_auth_token if didn't exist so npm doesn't complain&lt;/span&gt;
&lt;span class="c1"&gt;// about not being able to find it&lt;/span&gt;
&lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_AUTH_TOKEN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XXXXX-XXXXX-XXXXX-XXXXX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing that this is version-specific. On &lt;code&gt;main&lt;/code&gt; the action has changed to only export the variable when the user actually provided one, so if you are on a newer major you may not see this at all. If you are pinned to &lt;code&gt;v4&lt;/code&gt;, you will, and it looks exactly like a leaked credential.&lt;/p&gt;

&lt;p&gt;For completeness, a pre-set token would not block OIDC anyway. In &lt;code&gt;lib/commands/publish.js&lt;/code&gt;, &lt;code&gt;oidc()&lt;/code&gt; is called before credentials are read, and it overwrites the auth token directly:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;oidc&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;npm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;npm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCredentialsByURI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The actual cause
&lt;/h2&gt;

&lt;p&gt;The workflow pinned Node 20:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 20 bundles npm 10.8.2. npm documents the requirement as &lt;strong&gt;npm CLI 11.5.1 or later, and Node 22.14.0 or higher&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;npm 10 does not have a partial or broken implementation. It has none at all. You can verify this without reading a changelog:&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="k"&gt;for &lt;/span&gt;v &lt;span class="k"&gt;in &lt;/span&gt;10.8.2 11.5.0 11.5.1 11.19.0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;npm pack npm@&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
  &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ex-&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;tar &lt;/span&gt;xzf npm-&lt;span class="nv"&gt;$v&lt;/span&gt;.tgz &lt;span class="nt"&gt;-C&lt;/span&gt; ex-&lt;span class="nv"&gt;$v&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"npm &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;ex-&lt;span class="nv"&gt;$v&lt;/span&gt;/package/lib/utils/oidc.js 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'NO OIDC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm 10.8.2  -&amp;gt; NO OIDC
npm 11.5.0  -&amp;gt; ex-11.5.0/package/lib/utils/oidc.js
npm 11.5.1  -&amp;gt; ex-11.5.1/package/lib/utils/oidc.js
npm 11.19.0 -&amp;gt; ex-11.19.0/package/lib/utils/oidc.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So npm 10 never attempts the token exchange. It falls back to the &lt;code&gt;_authToken&lt;/code&gt; in the generated &lt;code&gt;.npmrc&lt;/code&gt;, which resolves to nothing useful, and publishes unauthenticated.&lt;/p&gt;

&lt;p&gt;You will notice 11.5.0 has the file too, which is worth explaining rather than glossing over. The OIDC code first shipped in 11.5.0, and the only difference in 11.5.1 is a scoping fix in the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-      let enableProvenance = false     // declared inside the inner block
&lt;/span&gt;&lt;span class="gi"&gt;+    let enableProvenance = false       // hoisted out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 11.5.0 the provenance-enabling branch sat inside an inner block, so it could not fire correctly. The token exchange itself is byte-identical between the two. That is why npm's documented floor is 11.5.1 rather than 11.5.0, and it is the version to pin. The distinction does not matter for the 404 in this post, because Node 20 gives you npm 10, which has nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an unauthenticated publish returns 404
&lt;/h2&gt;

&lt;p&gt;This is the part that wasted the most time.&lt;/p&gt;

&lt;p&gt;The publish went out with no usable credential at all, and the registry answered &lt;strong&gt;404&lt;/strong&gt; rather than 401 or 403. The explanation usually given is that answering 403 would confirm a package exists, letting anyone enumerate private package names by guessing, so the registry collapses "does not exist" and "you may not see this" into one response. I have not found npm stating that intent in those words, so take the reasoning as the community explanation and the behaviour as the observed fact.&lt;/p&gt;

&lt;p&gt;The distinction that actually helps you debug is this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;403&lt;/strong&gt; usually means your credential arrived and the identity behind it is not allowed to publish this package. The familiar form is &lt;code&gt;You do not have permission to publish "x". Are you logged in as the correct user?&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404&lt;/strong&gt; on a package you own usually means the registry never established who you are at all. The credential is missing, empty, or never sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So read &lt;code&gt;'chron-mcp@0.1.55' is not in this registry&lt;/code&gt; as a statement about &lt;strong&gt;your identity&lt;/strong&gt;, not about your package name. That is the most useful reframing in this post. I spent a long time re-checking the package name, the scope, and the registry URL, when the message was really telling me the request was anonymous.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part one
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt;
    &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure npm supports OIDC trusted publishing&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;npm install -g npm@^11.5.1&lt;/span&gt;
    &lt;span class="s"&gt;npm --version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 24 bundles npm 11.19.0, which is already sufficient. The explicit global install is a guard: Node 24.0.0 shipped npm 11.3.0, which is below the threshold, so pinning the major alone is not a guarantee. Printing &lt;code&gt;npm --version&lt;/code&gt; into the log means the next person to debug this can rule it out in one glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part two, which the first fix revealed
&lt;/h2&gt;

&lt;p&gt;With authentication working, the error changed. That is progress, even when it is still red:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm error code E422
npm error 422 Unprocessable Entity - PUT https://registry.npmjs.org/chron-mcp -
  Error verifying sigstore provenance bundle: Unsupported GitHub Actions source
  repository visibility: "private". Only public source repositories are
  supported when publishing with provenance.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provenance requires a &lt;strong&gt;public&lt;/strong&gt; source repository. The repo publishing this package is private, so provenance was never achievable, and &lt;code&gt;--provenance&lt;/code&gt; had been wrong from the first commit. It stayed invisible because the auth failure happened first.&lt;/p&gt;

&lt;p&gt;The fix is to drop the flag rather than to add &lt;code&gt;--no-provenance&lt;/code&gt;. From &lt;code&gt;lib/utils/oidc.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDefaultProvenance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;provenance&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;isDefaultProvenance&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ciInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CIRCLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* decoded id-token */&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="nx"&gt;ciInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_ACTIONS&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repository_visibility&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visibility&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;libaccess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVisibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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="nx"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kr"&gt;public&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provenance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm auto-enables provenance only when the flag was left at its default &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;repository_visibility&lt;/code&gt; is &lt;code&gt;"public"&lt;/code&gt;. Passing &lt;code&gt;--provenance&lt;/code&gt; explicitly sets &lt;code&gt;isDefault&lt;/code&gt; to false and forces it on regardless. Omitting it lets npm make the correct decision on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  npm publish --access public --provenance --ignore-scripts
&lt;/span&gt;&lt;span class="gi"&gt;+  npm publish --access public --ignore-scripts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green on the next run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The working workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;     &lt;span class="c1"&gt;# required for OIDC&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt;
      &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure npm supports OIDC trusted publishing&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;npm install -g npm@^11.5.1&lt;/span&gt;
      &lt;span class="s"&gt;npm --version&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --access public --ignore-scripts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;NODE_AUTH_TOKEN&lt;/code&gt;. No &lt;code&gt;NPM_TOKEN&lt;/code&gt; secret. The Trusted Publisher is configured on npmjs.com under the package's Settings, pointing at the repo and the workflow filename, and both have to match exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist if you hit this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Print &lt;code&gt;npm --version&lt;/code&gt; in the job.&lt;/strong&gt; npm 10 and below will not attempt OIDC at all, and 11.5.1 is npm's documented floor. This is the single highest-value check and it takes one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read a 404 on a package you own as an identity problem, not a naming one.&lt;/strong&gt; A 403 usually means your credential arrived and was rejected; a 404 usually means it was never established.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not treat a successful provenance step as proof that auth works.&lt;/strong&gt; They are separate features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXX&lt;/code&gt; is a literal placeholder,&lt;/strong&gt; not a masked secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only use &lt;code&gt;--provenance&lt;/code&gt; if the source repo is public.&lt;/strong&gt; Otherwise omit it and let npm decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the Trusted Publisher's workflow filename&lt;/strong&gt; matches the workflow that actually runs the publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general lesson is about error messages that withhold information. The 404 was not a bug. It was the registry saying it did not know who I was, in the least specific way available to it, and I read it literally for far longer than I should have.&lt;/p&gt;

</description>
      <category>npm</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
