<?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: Russell Jones</title>
    <description>The latest articles on DEV Community by Russell Jones (@jonesrussell).</description>
    <link>https://dev.to/jonesrussell</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%2F136661%2Fd812786d-8ef0-4b08-9421-35be6f99b174.png</url>
      <title>DEV Community: Russell Jones</title>
      <link>https://dev.to/jonesrussell</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonesrussell"/>
    <language>en</language>
    <item>
      <title>A missing npm ci silently starved the content-mining pipeline for a week</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 03 Sep 2026 11:44:10 +0000</pubDate>
      <link>https://dev.to/jonesrussell/a-missing-npm-ci-silently-starved-the-content-mining-pipeline-for-a-week-2kpj</link>
      <guid>https://dev.to/jonesrussell/a-missing-npm-ci-silently-starved-the-content-mining-pipeline-for-a-week-2kpj</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Earlier posts covered &lt;a href="https://jonesrussell.github.io/blog/automated-content-pipeline-github-actions/" rel="noopener noreferrer"&gt;building&lt;/a&gt; and &lt;a href="https://jonesrussell.github.io/blog/refining-content-pipeline-github-actions/" rel="noopener noreferrer"&gt;refining&lt;/a&gt; the automated content pipeline: a scheduled job scans recent commits, groups them by theme, and files GitHub issues as raw material for future posts. Every mined candidate gets validated against a JSON schema before the workflow files an issue for it — that's the guardrail that keeps malformed seeds out of the queue. For about a week, that guardrail silently dropped every candidate instead of filing a single one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke
&lt;/h2&gt;

&lt;p&gt;The mining script, &lt;code&gt;scripts/mine-git-activity.sh&lt;/code&gt;, builds a seed for each commit group and validates it with &lt;code&gt;schemas/validate.js&lt;/code&gt;, which loads the &lt;a href="https://ajv.js.org/" rel="noopener noreferrer"&gt;ajv&lt;/a&gt; JSON Schema validator:&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;Ajv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ajv&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;addFormats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ajv-formats&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;&lt;code&gt;ajv&lt;/code&gt; and &lt;code&gt;ajv-formats&lt;/code&gt; are &lt;code&gt;devDependencies&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;, so they only exist after &lt;code&gt;npm ci&lt;/code&gt; runs. The &lt;code&gt;content-mine.yml&lt;/code&gt; workflow checked out the blog repo and ran the mining script directly — it never installed Node dependencies first. Every call to the validator hit &lt;code&gt;Cannot find module 'ajv'&lt;/code&gt; and exited non-zero.&lt;/p&gt;

&lt;p&gt;The script treated that crash the same as a real validation failure: reject the candidate, move on. No error surfaced anywhere. The workflow itself finished green, because the script's own exit code was still &lt;code&gt;0&lt;/code&gt; — it just never filed anything. Net result: &lt;strong&gt;zero issues created, for about a week&lt;/strong&gt;, and nothing in the logs said so unless you went looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Fail Open, Warn Loud
&lt;/h2&gt;

&lt;p&gt;The first patch, in the blog repo, doesn't wait for CI to be fixed — it makes the script defend itself. Before validating anything, it checks whether the validator's dependencies actually resolve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VALIDATOR_OK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"require('ajv'); require('ajv-formats')"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARN: schema-validator deps (ajv) not installed — creating issues WITHOUT seed validation. Add 'npm ci' to the mining workflow to restore validation."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nv"&gt;VALIDATOR_OK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Downstream, the validation gate becomes an &lt;em&gt;or&lt;/em&gt;: skip validation and file the issue anyway if the deps aren't there, instead of letting the crash masquerade as "this candidate is invalid":&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;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VALIDATOR_OK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$VALIDATOR&lt;/span&gt; mined-seed &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SEED_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# ...build and create the issue...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a deliberate trade: an unvalidated issue is a minor cleanup problem for a human curator. A silently starved queue is a week of lost content ideas nobody knew to chase. Given the choice, fail open and be loud about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Actually Install the Dependency
&lt;/h2&gt;

&lt;p&gt;The warning fixes the symptom. The root cause was still the missing install step, patched separately in the &lt;code&gt;content-mine.yml&lt;/code&gt; workflow (in &lt;code&gt;jonesrussell/jonesrussell&lt;/code&gt;, the repo that owns the queue):&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node.js&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lts/*'&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&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;Install dependencies (ajv for seed validation)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, validation runs for real again — the fail-open guard in fix 1 becomes a safety net for the next unrelated dependency gap, not a permanent workaround for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed, at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing &lt;code&gt;ajv&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Validator crashes, treated as "invalid," issue silently dropped&lt;/td&gt;
&lt;td&gt;Absence detected up front, logs a &lt;code&gt;WARN&lt;/code&gt;, issue still filed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node deps in CI&lt;/td&gt;
&lt;td&gt;Never installed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;actions/setup-node&lt;/code&gt; + &lt;code&gt;npm ci&lt;/code&gt; before the script runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure visibility&lt;/td&gt;
&lt;td&gt;None — 0 issues/week, workflow exits &lt;code&gt;0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Explicit warning line in job logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;A script that "succeeds" by doing nothing is worse than one that fails loudly, because nothing pages you for it. The mining workflow's exit code was never wrong; it was just measuring the wrong thing. The mining job &lt;a href="https://jonesrussell.github.io/blog/automated-content-pipeline-github-actions/" rel="noopener noreferrer"&gt;runs daily&lt;/a&gt;, so this wasn't one bad run — it was seven straight silent failures before the empty queue was noticeable on its own. Exit code isn't the metric that would have caught it. Throughput is: issues filed per run, checked against zero. That's the check I was missing.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>automation</category>
      <category>bash</category>
      <category>ci</category>
    </item>
    <item>
      <title>Idempotent, budget-bounded AI queries on a single SQLite writer</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:22:30 +0000</pubDate>
      <link>https://dev.to/jonesrussell/idempotent-budget-bounded-ai-queries-on-a-single-sqlite-writer-ag5</link>
      <guid>https://dev.to/jonesrussell/idempotent-budget-bounded-ai-queries-on-a-single-sqlite-writer-ag5</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jonesrussell/northway" rel="noopener noreferrer"&gt;northway&lt;/a&gt; is a Go service that turns approved sources into small, ranked, source-backed news feeds for AI agents — one process, embedded SQLite, deployed Pi-first. Every feed query that reaches an AI provider costs real money, and a network retry or a crashed request must never turn into a second charge for the same query. &lt;code&gt;internal/sqlite&lt;/code&gt; is what makes that guarantee hold: one writer, scoped credentials, a reserve-then-settle budget, and an idempotent claim lifecycle for the provider call itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  One writer, strictly locked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Store&lt;/code&gt; owns a single SQLite file and refuses to share it. &lt;code&gt;Open&lt;/code&gt; takes an &lt;code&gt;flock&lt;/code&gt;-style exclusive lock on the database file and requires the directory to be private:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Perm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;0077&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"database directory must be private (0700) and not a symlink"&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;Writes go through one &lt;code&gt;*sql.DB&lt;/code&gt; with &lt;code&gt;_txlock=immediate&lt;/code&gt;, and a buffered channel (&lt;code&gt;writeGate&lt;/code&gt;) serializes callers before any transaction opens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sqlc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeGate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeGate&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BeginTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads use a separate read-only pool (&lt;code&gt;query_only(1)&lt;/code&gt;) with up to two connections, so lookups never queue behind the write gate. WAL mode, &lt;code&gt;foreign_keys(1)&lt;/code&gt;, and &lt;code&gt;synchronous(FULL)&lt;/code&gt; are all asserted — not just set — by &lt;code&gt;Ready()&lt;/code&gt;, which fails startup if the file's actual pragmas don't match what the binary expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoped keys, one cross-tenant lookup
&lt;/h2&gt;

&lt;p&gt;Every credential is tenant-scoped. &lt;code&gt;identity.go&lt;/code&gt; stores a key's SHA-256 digest, never the key itself, and validates the full record shape before writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidKeyID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scopes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Valid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;validTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Digest&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastUsedAt&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RevokedAt&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid key metadata"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LookupAPIKey&lt;/code&gt; is deliberately the &lt;strong&gt;only&lt;/strong&gt; function in the package that resolves a key without an existing tenant scope — its own comment spells out the boundary: it "exposes no corpus data and is consumed only by identity.Service, never a public lookup endpoint." Everything downstream of that lookup — &lt;code&gt;TouchAPIKey&lt;/code&gt;, &lt;code&gt;RevokeAPIKey&lt;/code&gt;, every query and mutation — takes a &lt;code&gt;tenant&lt;/code&gt; and checks access before touching a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve first, settle later
&lt;/h2&gt;

&lt;p&gt;Budgets are tracked in micros (&lt;code&gt;LimitMicros&lt;/code&gt;, &lt;code&gt;SpentMicros&lt;/code&gt;, &lt;code&gt;HeldMicros&lt;/code&gt;) and set through an operator-only call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limitMicros&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOperator&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spend itself never just decrements a counter after the fact. A query first &lt;strong&gt;reserves&lt;/strong&gt; a worst-case amount; only once the real cost is known does it &lt;strong&gt;settle&lt;/strong&gt; the difference back. That two-step is what makes an in-flight AI call safe to retry, fail, or recover from a crash without double-billing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query claim lifecycle
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BeginQuery&lt;/code&gt; is the entry point, and it's built to be replay-safe. It hashes the caller's idempotency key together with the endpoint (&lt;code&gt;sha256("POST /v1/feed-queries\x00" + key)&lt;/code&gt;) and looks for existing work under that hash:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit&lt;/strong&gt; (same feed/corpus/entitlement/ranker revision as an existing snapshot) → returns the cached result, no budget touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing work found, same request digest&lt;/strong&gt; → returns the in-progress or completed claim; a second call with the same key can never start a second paid attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing work found, different request digest&lt;/strong&gt; → &lt;code&gt;ErrConflict&lt;/code&gt;. Reusing an idempotency key for a different request is rejected outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No existing work, budget configured&lt;/strong&gt; → reserves &lt;code&gt;policy.WorstCaseMicros&lt;/code&gt; and returns a &lt;code&gt;WorkID&lt;/code&gt; the caller can use to actually invoke the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No existing work, no budget&lt;/strong&gt; → still creates work, but &lt;code&gt;ProviderAllowed&lt;/code&gt; is false — the caller is limited to deterministic (non-AI) results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The function's own comment is blunt about the safety property: "A replay never returns WorkID, so it cannot authorize another call."&lt;/p&gt;

&lt;p&gt;From there, three methods carry a claim through its lifecycle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Precondition&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StartProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;work is &lt;code&gt;pending&lt;/code&gt;, spend is &lt;code&gt;reserved&lt;/code&gt;, lease not expired, scope unchanged&lt;/td&gt;
&lt;td&gt;flips spend to &lt;code&gt;started&lt;/code&gt; — must commit &lt;em&gt;before&lt;/em&gt; the provider is actually called&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CompleteQuery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;work is &lt;code&gt;pending&lt;/code&gt;, lease valid, feed/entitlement revision unchanged&lt;/td&gt;
&lt;td&gt;validates every selected article against current storage, settles spend, writes an immutable snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FailQuery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;work is &lt;code&gt;pending&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;settles the hold as &lt;code&gt;uncertain&lt;/code&gt; if the provider had started, marks work &lt;code&gt;failed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;StartProvider&lt;/code&gt;'s doc comment states the ordering guarantee directly: "An error/ambiguous commit never authorizes a call... No provider code runs inside storage." The database transaction is the gate, not the HTTP call to the provider.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CompleteQuery&lt;/code&gt; re-checks the feed's revision and each selected article's content hash before it will settle spend or write a snapshot — an unrelated ingest arriving mid-request can't get charged for or attached to a query that started against older data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering from crashes without guessing
&lt;/h2&gt;

&lt;p&gt;Every claim carries a &lt;code&gt;LeaseUntil&lt;/code&gt;. If a process dies mid-call, the spend is left in &lt;code&gt;reserved&lt;/code&gt; or &lt;code&gt;started&lt;/code&gt;, not silently lost or silently spent. &lt;code&gt;RecoverQueries&lt;/code&gt; sweeps expired leases and fails them the same way &lt;code&gt;FailQuery&lt;/code&gt; would — its comment notes it processes bounded batches and "preserves uncertain holds," since an operator later needs to reconcile those, not assume they were free.&lt;/p&gt;

&lt;p&gt;That reconciliation is &lt;code&gt;ReconcileQuery&lt;/code&gt;, and it requires actual evidence, not elapsed time, before it will move a hold out of &lt;code&gt;uncertain&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpendState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"settled"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActualMicros&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int64&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;actualMicros&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrConflict&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpendState&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"uncertain"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrConflict&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeating the same settlement is a safe no-op; a contradictory number is rejected outright rather than silently overwritten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timestamps get the same suspicion
&lt;/h2&gt;

&lt;p&gt;Money isn't the only thing storage refuses to trust blindly. &lt;code&gt;timestamps_test.go&lt;/code&gt; asserts that out-of-range values — before the epoch, past &lt;code&gt;9999-12-31&lt;/code&gt;, or offsets that push a boundary value across it — are rejected on write and never partially applied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="s"&gt;"positive wrap"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="s"&gt;"offset past end"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FixedZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"west"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rejected write leaves the row, its version history, and its full-text index completely unchanged — the test checks the version count and search index directly, not just the returned error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape
&lt;/h2&gt;

&lt;p&gt;None of this is exotic SQLite usage — WAL mode, a single writer, a lease column. What makes it hold together is that every step that touches money or identity states its own precondition in code, not just in a comment: wrong state, expired lease, mismatched revision, or a reused key against a different request all fail the transaction outright. For a Raspberry Pi-deployed, single-process service fronting paid AI calls, that's the difference between "retry-safe" as a claim and as a property you can point to in the diff.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>go</category>
      <category>sqlite</category>
      <category>concurrency</category>
      <category>apidesign</category>
    </item>
    <item>
      <title>Hardening a manual Claude Code review workflow in GitHub Actions</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:22:25 +0000</pubDate>
      <link>https://dev.to/jonesrussell/hardening-a-manual-claude-code-review-workflow-in-github-actions-mi8</link>
      <guid>https://dev.to/jonesrussell/hardening-a-manual-claude-code-review-workflow-in-github-actions-mi8</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/goformx/goformx" rel="noopener noreferrer"&gt;goformx&lt;/a&gt; uses a mixed-provider workflow: Codex writes some pull requests, Claude writes others, and each gets reviewed by the other provider on request. The Claude side runs on the official &lt;a href="https://github.com/anthropics/claude-code-action" rel="noopener noreferrer"&gt;Claude Code Action&lt;/a&gt;, triggered by a maintainer commenting &lt;code&gt;@claude review&lt;/code&gt; on a PR. That sounds simple to wire up. It wasn't safe to wire up as-is, because the action's default "tag mode" is built for implementation, not read-only review — three gaps in the default, five fixes to close them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Default Example Wasn't Enough
&lt;/h2&gt;

&lt;p&gt;The action ships an official &lt;a href="https://github.com/anthropics/claude-code-action/blob/a874e9ecd7bb36efdad65429c6b35815f5a08f10/examples/pr-review-comprehensive.yml" rel="noopener noreferrer"&gt;progress-tracked review example&lt;/a&gt; that auto-triggers on every PR event and posts a tracked comment. It's a good starting point, but three things about tag mode don't fit a review-only job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tag mode is implementation-capable by default.&lt;/strong&gt; It's designed for "&lt;a class="mentioned-user" href="https://dev.to/claude"&gt;@claude&lt;/a&gt; fix this," not "&lt;a class="mentioned-user" href="https://dev.to/claude"&gt;@claude&lt;/a&gt; look but don't touch."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions expression matching is case-insensitive.&lt;/strong&gt; An &lt;code&gt;if:&lt;/code&gt; condition comparing a comment body to &lt;code&gt;@claude review&lt;/code&gt; will also match &lt;code&gt;@Claude Review&lt;/code&gt;, &lt;code&gt;@CLAUDE REVIEW&lt;/code&gt;, and anything else GitHub considers equal — not the exact trigger you intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool permissions in the SDK accumulate, they don't replace.&lt;/strong&gt; Passing a restricted tool list doesn't override the mode's defaults; both apply, so an allow-list alone can't get you to read-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow adapts the official example at a pinned action revision, keeps the manual-only trigger and subscription auth, and layers on the following fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Enforce the Exact Trigger
&lt;/h2&gt;

&lt;p&gt;The outer &lt;code&gt;if:&lt;/code&gt; on the job is a first, cheap filter, but because Actions expressions are case-insensitive it can't be the whole check. A trusted &lt;code&gt;actions/github-script&lt;/code&gt; preflight step re-validates everything in JavaScript before any model call happens:&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;comment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&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;comment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&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;issue&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pull_request&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@claude review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jonesrussell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jonesrussell&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="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;An exact human maintainer review request is required.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rejects extra text, trailing newlines, wrong casing, bot comments, and anyone who isn't the maintainer — all before the job spends a single turn talking to Claude. It also checks that the PR is still open, and records &lt;code&gt;head_sha&lt;/code&gt;/&lt;code&gt;base_sha&lt;/code&gt; for later verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Bound the Diff, Fail Closed
&lt;/h2&gt;

&lt;p&gt;Tag mode's native changed-file context is a file list, not a diff — Claude would have to reconstruct the actual changes from scratch. Instead, a setup step downloads the real diff to runner-temp storage with a hard size cap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pulls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pull_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mediaType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diff&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&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;1048576&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Review diff is missing, invalid or exceeds the 1 MiB review bound.&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="nf"&gt;require&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:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diffPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wx&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;Missing, empty, or oversized diffs fail the job before Claude ever runs. The cap is &lt;strong&gt;1 MiB&lt;/strong&gt;; there's no silent fallback to a partial or truncated review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Deny Tools Instead of Just Allowing Them
&lt;/h2&gt;

&lt;p&gt;The SDK's argument parser accumulates &lt;code&gt;allowedTools&lt;/code&gt; rather than replacing the mode defaults, so the workflow can't rely on an allow-list alone. It sets the permission mode explicitly, restricts built-in tools to &lt;strong&gt;Read, Glob, Grep&lt;/strong&gt;, and layers an explicit deny list on top:&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;claude_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
  &lt;span class="s"&gt;--max-turns 16&lt;/span&gt;
  &lt;span class="s"&gt;--permission-mode default&lt;/span&gt;
  &lt;span class="s"&gt;--setting-sources user&lt;/span&gt;
  &lt;span class="s"&gt;--strict-mcp-config&lt;/span&gt;
  &lt;span class="s"&gt;--tools "Read,Glob,Grep"&lt;/span&gt;
  &lt;span class="s"&gt;--disallowedTools "Bash,Edit,Write,NotebookEdit,Agent,Task,mcp__github_ci__*,mcp__github_file_ops__*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some of those denials cover capabilities that aren't even enabled at the pinned action revision. That's deliberate — defense against future defaults changing underneath the pin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Keep Git Metadata Off Limits
&lt;/h2&gt;

&lt;p&gt;Tag mode checks out the PR head and stores its short-lived job token in &lt;code&gt;.git/config&lt;/code&gt;. A review agent with unrestricted &lt;code&gt;Read&lt;/code&gt; could read that token straight out of the checkout. The workflow denies it explicitly in the action's settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"disableAllHooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Read(./.git)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Read(./.git/**)"&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Denying reads to &lt;code&gt;.git&lt;/code&gt; also blocks &lt;code&gt;Grep&lt;/code&gt;/&lt;code&gt;Glob&lt;/code&gt; from searching that directory, and hooks are disabled entirely so only user settings and the action's own MCP configuration load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: Catch Stale or Unverified Reviews
&lt;/h2&gt;

&lt;p&gt;A successful job run isn't proof the review still applies. If the PR gets pushed to while Claude is reviewing it, the findings could describe a revision that no longer exists. A final step re-checks the PR's current &lt;code&gt;head_sha&lt;/code&gt;/&lt;code&gt;base_sha&lt;/code&gt; against what the preflight recorded, and confirms the local checkout matches too:&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;checkedHead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&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:child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;git&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rev-parse&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;HEAD&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="na"&gt;encoding&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&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;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;REVIEW_HEAD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&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;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;REVIEW_BASE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;checkedHead&lt;/span&gt; &lt;span class="o"&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;REVIEW_HEAD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any of those checks fail, the workflow posts a comment marking the review &lt;strong&gt;STALE or UNVERIFIED&lt;/strong&gt; with a link to the run, and fails the job. That comment matters as much as the review itself — without it, a stale finding just sits there looking authoritative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed, at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Default tag mode&lt;/th&gt;
&lt;th&gt;Hardened workflow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger matching&lt;/td&gt;
&lt;td&gt;Case-insensitive &lt;code&gt;if:&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;JS preflight enforces exact comment, author, PR state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diff visibility&lt;/td&gt;
&lt;td&gt;File list only&lt;/td&gt;
&lt;td&gt;Downloaded diff, 1 MiB bound, fails closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool access&lt;/td&gt;
&lt;td&gt;Implementation-capable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Read,Glob,Grep&lt;/code&gt; only, explicit deny list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git metadata&lt;/td&gt;
&lt;td&gt;Job token readable in &lt;code&gt;.git/config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Read&lt;/code&gt; denied for &lt;code&gt;.git&lt;/code&gt; and its contents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale results&lt;/td&gt;
&lt;td&gt;Not checked&lt;/td&gt;
&lt;td&gt;Head/base/checkout re-verified, STALE comment on mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing Without Spending a Turn
&lt;/h2&gt;

&lt;p&gt;The workflow's own tests don't call the model at all. A separate CI job runs mocked-response tests against the preflight and revision-check logic, plus &lt;a href="https://github.com/rhysd/actionlint" rel="noopener noreferrer"&gt;actionlint&lt;/a&gt; against both workflow files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--test&lt;/span&gt; .github/scripts/manual-review.test.cjs
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 &lt;span class="nt"&gt;-shellcheck&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-pyflakes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  .github/workflows/manual-claude-review.yml .github/workflows/manual-review-tests.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That proves the guardrails and workflow syntax are correct. It doesn't prove the provider's runtime tool enforcement — a live &lt;code&gt;@claude review&lt;/code&gt; request against an authorized PR is still the only way to confirm end to end that the final comment gets posted and failures are reported correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Budget Stays Bounded Too
&lt;/h2&gt;

&lt;p&gt;The job itself is capped three ways: &lt;strong&gt;16 turns&lt;/strong&gt;, an &lt;strong&gt;8-minute&lt;/strong&gt; limit on the review step, and a &lt;strong&gt;20-minute&lt;/strong&gt; ceiling on the whole job. Auth comes from a &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; repository secret tied to a Claude Max subscription. There's no API-key fallback, so a misconfigured secret fails the preflight instead of silently billing somewhere else.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's the same instinct as any other CI hardening: don't trust a string comparison you haven't tested for case sensitivity, don't hand out more filesystem access than the job needs, and don't let a slow job's output outlive the code it described.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>claudecode</category>
      <category>ci</category>
      <category>security</category>
    </item>
    <item>
      <title>Publishing a versioned API contract you can actually trust</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 31 Aug 2026 02:38:22 +0000</pubDate>
      <link>https://dev.to/jonesrussell/publishing-a-versioned-api-contract-you-can-actually-trust-3og3</link>
      <guid>https://dev.to/jonesrussell/publishing-a-versioned-api-contract-you-can-actually-trust-3og3</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;If you ship a public API, you eventually hit the same question from every client: "what exactly am I integrating against, and will it change under me?" &lt;a href="https://github.com/goformx/goformx" rel="noopener noreferrer"&gt;goformx&lt;/a&gt;, a Go forms service, answers with a generated OpenAPI contract that gets frozen, checksummed, and published as its own release artifact, separate from the application's version tags. Here's how that pipeline works: the CI drift check, the release packaging script, and the rules the published docs give clients for staying compatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: generated code that quietly goes stale
&lt;/h2&gt;

&lt;p&gt;goformx generates its TypeScript client types from an OpenAPI 3.1 spec using &lt;code&gt;openapi-typescript&lt;/code&gt;. Generated files are easy to forget to regenerate after an API change, and once that happens, the published client silently disagrees with the server. The fix is a CI gate that fails the build if generated artifacts don't match a clean regeneration — &lt;code&gt;goforms/contracts/check-generated.mjs&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execFileSync&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="s2"&gt;node:child_process&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;fileURLToPath&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="s2"&gt;node:url&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;cwd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fileURLToPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;git&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;diff&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="s2"&gt;--exit-code&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="s2"&gt;--&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="s2"&gt;contracts/generated&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="nx"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inherit&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;untracked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;git&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ls-files&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="s2"&gt;--others&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="s2"&gt;--exclude-standard&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="s2"&gt;--&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="s2"&gt;contracts/generated&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="nx"&gt;cwd&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&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;untracked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Generated artifacts must be committed:\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;untracked&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It regenerates the contract, then checks two things: no diff against what's committed, and no new untracked files under &lt;code&gt;contracts/generated&lt;/code&gt;. Either failure means someone changed the API without regenerating and committing the client types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging a frozen release
&lt;/h2&gt;

&lt;p&gt;Passing CI isn't the same as being safe to integrate against — &lt;code&gt;main&lt;/code&gt; still moves. goformx solves that with &lt;code&gt;package-release.mjs&lt;/code&gt;, which turns a specific commit into an immutable, checksummed bundle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refuses a dirty tree.&lt;/strong&gt; It runs &lt;code&gt;git status --porcelain&lt;/code&gt; first and throws if there are uncommitted changes — only a clean, verified commit gets packaged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashes committed bytes, not a checkout.&lt;/strong&gt; It reads file contents with &lt;code&gt;git show HEAD:&amp;lt;path&amp;gt;&lt;/code&gt; instead of the filesystem, so a contributor's line-ending settings can't change the published SHA-256.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requires an explicit semantic version.&lt;/strong&gt; It reads &lt;code&gt;info.version&lt;/code&gt; out of the generated &lt;code&gt;openapi.json&lt;/code&gt; and rejects anything that isn't &lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Builds a manifest of content-addressed URLs.&lt;/strong&gt; Each artifact (OpenAPI spec, form-definition schema, auth assertion schema, generated client types, example archive) gets a &lt;code&gt;raw.githubusercontent.com/&amp;lt;repo&amp;gt;/&amp;lt;exact-commit-sha&amp;gt;/...&lt;/code&gt; URL plus its own SHA-256, so a client can verify what it downloaded actually matches that commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never touches the network.&lt;/strong&gt; The script only writes files to a local, git-ignored &lt;code&gt;.contract-release/&lt;/code&gt; directory — publishing the GitHub release is a separate, deliberate step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters: packaging and publishing are decoupled on purpose, so building the artifact can't accidentally ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What clients are told to do with it
&lt;/h2&gt;

&lt;p&gt;The published guide, &lt;code&gt;docs/api-clients.md&lt;/code&gt;, gives external integrators (including the project's own agent tooling) a discovery list rather than a single "latest" link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current release tag, manifest, OpenAPI download, client example archive, and &lt;code&gt;SHA256SUMS&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Links to the &lt;strong&gt;previous&lt;/strong&gt; frozen versions (so a client mid-migration isn't stranded)&lt;/li&gt;
&lt;li&gt;A pointer to the &lt;strong&gt;current development contract&lt;/strong&gt; on &lt;code&gt;main&lt;/code&gt;, explicitly labeled as not immutable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The guidance is blunt about the distinction that matters most: a published contract describes the &lt;em&gt;interface&lt;/em&gt;, not the &lt;em&gt;deployment state&lt;/em&gt; of any given server. Clients are told to confirm the target deployment actually supports the pinned version before integrating — a frozen spec is not proof anything is live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The credential model, at a glance
&lt;/h2&gt;

&lt;p&gt;The same doc lays out three credential types and where each is allowed to live:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Where it belongs&lt;/th&gt;
&lt;th&gt;Authority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gfpk_&lt;/code&gt; public form key&lt;/td&gt;
&lt;td&gt;Browser embeds and public submission clients&lt;/td&gt;
&lt;td&gt;Published schema/submission access only; never management access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gfst_&lt;/code&gt; service token&lt;/td&gt;
&lt;td&gt;External agents and custom-dashboard servers, in secret custody&lt;/td&gt;
&lt;td&gt;One organization, explicit scopes, expiry and revocation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-party assertion (&lt;code&gt;gofx-fpa+jwt&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Server-to-server request only&lt;/td&gt;
&lt;td&gt;Verified user and resolved organization; signed, audience-bound, single-use, at most &lt;strong&gt;60 seconds&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every management operation also declares its own &lt;code&gt;x-goformx-required-scopes&lt;/code&gt; in the spec — &lt;strong&gt;eight scopes total&lt;/strong&gt; (&lt;code&gt;forms:read/write/publish&lt;/code&gt;, &lt;code&gt;submissions:read&lt;/code&gt;, &lt;code&gt;tokens:read/write&lt;/code&gt;, &lt;code&gt;webhooks:read/write&lt;/code&gt;). Write access doesn't implicitly grant publish access. Reading submissions doesn't implicitly grant webhook configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantics that outlive any one endpoint
&lt;/h2&gt;

&lt;p&gt;A few rules from the contract are the kind of thing that usually gets documented only after someone gets bitten by their absence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Additive fields need an explicit contract bump.&lt;/strong&gt; Contract &lt;strong&gt;1.1.0&lt;/strong&gt; added a stored &lt;code&gt;allowedOrigins&lt;/code&gt; array to form responses. The docs are explicit that an empty array means no cross-origin grant — never treat it as a wildcard — and that older servers omitting the field entirely is not the same as an empty configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numeric precision is a first-class guarantee.&lt;/strong&gt; Contract &lt;strong&gt;1.1.1&lt;/strong&gt; states that schemas and submission values retain numeric precision, with published token/exponent/decimal-place budgets, because JSONB storage can normalize spelling but must not normalize value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency is ETag-based.&lt;/strong&gt; Metadata &lt;code&gt;PATCH&lt;/code&gt; requires the ETag from a current &lt;code&gt;GET&lt;/code&gt; in &lt;code&gt;If-Match&lt;/code&gt;; a &lt;code&gt;428&lt;/code&gt; means fetch a fresh validator, a &lt;code&gt;412&lt;/code&gt; means reconcile before retrying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys are retry-scoped, not create-scoped.&lt;/strong&gt; Public submissions require one; management creation has no general idempotency contract, so an uncertain create/publish response has to be reconciled rather than blindly retried.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The release checklist
&lt;/h2&gt;

&lt;p&gt;Maintainers publish a new frozen contract with a fixed sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bump &lt;code&gt;info.version&lt;/code&gt; for a new contract — never republish an existing version. Regenerate, verify, and commit source and generated files.&lt;/li&gt;
&lt;li&gt;Run the packaging script from &lt;code&gt;goforms/&lt;/code&gt;, which writes the manifest, OpenAPI copy, checksums, and client archive to the ignored release directory without any network call.&lt;/li&gt;
&lt;li&gt;Create a &lt;code&gt;contract-vVERSION&lt;/code&gt; tag at that exact commit and attach the four files to its GitHub release (not an application &lt;code&gt;v*&lt;/code&gt; tag — contract releases are versioned independently).&lt;/li&gt;
&lt;li&gt;Download the published artifacts anonymously, verify the SHA-256 digests and commit-pinned URLs, and run the example client against them.&lt;/li&gt;
&lt;li&gt;Update the discovery doc for the new version, keeping the prior versions' links live.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Separating the contract's version from the application's version, and verifying the published artifact anonymously after the fact, closes the gap between "we merged it" and "an outside client can safely depend on it."&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>go</category>
      <category>api</category>
      <category>openapi</category>
      <category>apidesign</category>
    </item>
    <item>
      <title>Running the blog's content autopilot on a Claude subscription, not API credit</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Fri, 21 Aug 2026 23:52:30 +0000</pubDate>
      <link>https://dev.to/jonesrussell/running-the-blogs-content-autopilot-on-a-claude-subscription-not-api-credit-4g2p</link>
      <guid>https://dev.to/jonesrussell/running-the-blogs-content-autopilot-on-a-claude-subscription-not-api-credit-4g2p</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://jonesrussell.github.io/blog/content-autopilot/" rel="noopener noreferrer"&gt;https://jonesrussell.github.io/blog/content-autopilot/&lt;/a&gt; covers how this blog's &lt;a href="https://github.com/jonesrussell/blog/blob/main/.github/workflows/content-autopilot.yml" rel="noopener noreferrer"&gt;Content Autopilot&lt;/a&gt; workflow mines, curates, and publishes without a human reading the draft first. Three things broke or needed hardening after that pipeline went live: how it pays for Claude, why the auth silently died, and the two gates bolted on afterward to catch a bad draft before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching off pay-per-token API credit
&lt;/h2&gt;

&lt;p&gt;The produce step originally authenticated with &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, a pay-as-you-go API account billed per token. That account ran dry, and the daily cron started failing on the very first Claude call. Swapping to &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; fixed it in one line: this env var runs the headless CLI against a Claude subscription instead.&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="c1"&gt;# Subscription auth (no per-token API billing). See CLAUDE_CODE_OAUTH_TOKEN secret.&lt;/span&gt;
  &lt;span class="na"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}&lt;/span&gt;
  &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CROSS_REPO_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude setup-token&lt;/code&gt; mints this token locally. It's a short-lived credential meant for one CLI session, not a service account, and that mismatch is exactly what caused the next failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A revoked token, four silent failures
&lt;/h2&gt;

&lt;p&gt;Subscription tokens rotate. This one got revoked, and the workflow had no step watching for a &lt;code&gt;failure()&lt;/code&gt; outcome. Four daily runs failed in a row before anyone noticed, because a cron job with no output channel just... stops. Nobody was checking the Actions tab every morning.&lt;/p&gt;

&lt;p&gt;The fix wasn't a longer-lived token. It was making failure loud:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Alert on failure&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
    &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
    &lt;span class="na"&gt;RUN_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}&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;gh label create autopilot-failure --repo "$REPO" --color d73a4a \&lt;/span&gt;
      &lt;span class="s"&gt;--description "Content Autopilot run failed" 2&amp;gt;/dev/null || true&lt;/span&gt;
    &lt;span class="s"&gt;body=$(printf 'Content Autopilot run failed: %s\n\nMost common cause: CLAUDE_CODE_OAUTH_TOKEN expired or was revoked (subscription tokens rotate). Re-sync the secret from a fresh token, or run `claude setup-token` for a long-lived one.\n\n@jonesrussell' "$RUN_URL")&lt;/span&gt;
    &lt;span class="s"&gt;existing=$(gh issue list --repo "$REPO" --label autopilot-failure --state open --json number --jq '.[0].number // empty')&lt;/span&gt;
    &lt;span class="s"&gt;if [ -n "$existing" ]; then&lt;/span&gt;
      &lt;span class="s"&gt;gh issue comment "$existing" --repo "$REPO" --body "$body"&lt;/span&gt;
    &lt;span class="s"&gt;else&lt;/span&gt;
      &lt;span class="s"&gt;gh issue create --repo "$REPO" --title "Content Autopilot is failing" \&lt;/span&gt;
        &lt;span class="s"&gt;--label autopilot-failure --body "$body"&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs on any step failure in the job, not just the auth step. It opens one issue, reuses it on repeat failures instead of spamming a new one per day, and GitHub's default notification settings turn that issue into an email. The &lt;code&gt;permissions&lt;/code&gt; block needed &lt;code&gt;issues: write&lt;/code&gt; added alongside the existing &lt;code&gt;contents: write&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt; for this to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Refine: one critique pass before publish
&lt;/h2&gt;

&lt;p&gt;Auth failures are loud and easy to fix. A boring, over-hedged, cliche-riddled post is a quieter failure, and nothing upstream of publish was checking for it. The next addition borrows the Self-Refine technique from the AI-writing research: have the model critique its own output once, then revise, before anything ships.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Critique and then revise the blog post at &amp;lt;path&amp;gt; and its social copy.
Rubric: (1) voice matches docs/blog-style.md and the essay reference;
(2) every claim is backed by a concrete fact, number, or reference;
(3) sentence lengths vary (no runs of same-length sentences);
(4) zero phrases from the site's banned AI-cliche list;
(5) intro scope line is not the phrase 'This post covers'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The step is &lt;code&gt;continue-on-error: true&lt;/code&gt;. If the critique call itself fails or times out, the workflow falls back to publishing the un-refined first draft rather than blocking the whole run over a quality pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slop gate: a linter, not a model
&lt;/h2&gt;

&lt;p&gt;A revise pass helps, but it's still the same model marking its own homework. The step after it is deliberately dumb: &lt;code&gt;scripts/slop-check.mjs&lt;/code&gt; runs no API call at all. It strips frontmatter, code fences, and markdown syntax, splits what's left into sentences, and scores three signals.&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;mean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalWords&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;lens&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;lens&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stdev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variance&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;burstiness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stdev&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// human ~0.6-1.2, AI slop &amp;lt; 0.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Human writing varies sentence length a lot; AI writing tends to cluster around one length. The script also counts em dashes per thousand words (over 20 reads as machine-written) and matches a growing list of stock corporate-blog phrases pulled straight from the site's own style guide. It fails the draft on a hard burstiness floor of 0.32. Three or more banned-phrase hits alone are enough too, and so is any two of the three signals firing together. A failed check holds the draft: nothing gets committed, and the source issue stays open for the next day's run to try again.&lt;/p&gt;

&lt;p&gt;Build order matters here. The slop gate runs before the Hugo build gate, so a flagged draft never even reaches &lt;code&gt;hugo --gc --minify&lt;/code&gt;, let alone &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Two posts have shipped through this pipeline since it went live, tracked by issue number in &lt;code&gt;data/autopilot-ledger.json&lt;/code&gt;. None of the hardening above came from planning ahead. Each piece exists because a specific run failed in a specific way: out-of-balance API credit, then a revoked token nobody caught, then no evidence-based reason to trust an unreviewed draft's prose. Unsupervised doesn't mean untested. It means every failure mode has to turn into a gate before the same thing is allowed to happen twice.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>claude</category>
      <category>githubactions</category>
      <category>automation</category>
      <category>hugo</category>
    </item>
    <item>
      <title>Splitting Anokii into composable core, identity, and operator packages</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Fri, 21 Aug 2026 04:15:22 +0000</pubDate>
      <link>https://dev.to/jonesrussell/splitting-anokii-into-composable-core-identity-and-operator-packages-1he2</link>
      <guid>https://dev.to/jonesrussell/splitting-anokii-into-composable-core-identity-and-operator-packages-1he2</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/anokii" rel="noopener noreferrer"&gt;Anokii&lt;/a&gt; is a distribution built on the &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; framework. It used to carry its own copy of identity handling duplicated across the root project instead of living in one place. This post covers how that got split into three composable Composer packages, core, identity, and operator, and the governed CI workflow that publishes them without a full release process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Duplicate Identity, No Boundary
&lt;/h2&gt;

&lt;p&gt;Before the split, the root Anokii distribution kept its own Identity implementation instead of depending on a single canonical one. Any change to authentication or permissions had to be made carefully in more than one place, and there was no enforced boundary stopping other code from reaching into identity internals.&lt;/p&gt;

&lt;p&gt;The fix was to extract two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;waaseyaa/anokii-core&lt;/code&gt;&lt;/strong&gt;, shared primitives only, nothing domain-specific.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;waaseyaa/anokii-identity&lt;/code&gt;&lt;/strong&gt;, one canonical identity domain: entity, service, permissions, policy, provider, migrations, and an opt-in read-only host surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A third package, &lt;strong&gt;&lt;code&gt;waaseyaa/anokii-operator&lt;/code&gt;&lt;/strong&gt;, followed the same pattern. The root distribution now consumes all three instead of maintaining its own duplicate implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composer Path Repositories Keep Local Dev Fast
&lt;/h2&gt;

&lt;p&gt;Anokii's root &lt;code&gt;composer.json&lt;/code&gt; points at each package directory as a local path repository, symlinked, so changes inside &lt;code&gt;packages/core&lt;/code&gt; or &lt;code&gt;packages/identity&lt;/code&gt; are picked up immediately without a publish step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"repositories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"packages/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"symlink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"versions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"waaseyaa/anokii-core"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev-main"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"packages/identity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"symlink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"versions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"waaseyaa/anokii-identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev-main"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"waaseyaa/anokii-core"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev-main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"waaseyaa/anokii-identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev-main"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;symlink&lt;/code&gt; option means editing a file under &lt;code&gt;packages/identity/src&lt;/code&gt; is the same as editing it in the standalone &lt;code&gt;anokii-identity&lt;/code&gt; package, no &lt;code&gt;composer update&lt;/code&gt; round trip. The monorepo stays the single source of truth: everything is authored in &lt;code&gt;waaseyaa/anokii&lt;/code&gt;, not in the split repos directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Split-Main Workflow
&lt;/h2&gt;

&lt;p&gt;Getting code out of the monorepo and into standalone &lt;code&gt;anokii-core&lt;/code&gt; and &lt;code&gt;anokii-identity&lt;/code&gt; repositories is a &lt;code&gt;workflow_dispatch&lt;/code&gt; job, not a script anyone runs by hand. It takes three inputs: the exact 40-character &lt;code&gt;main&lt;/code&gt; SHA to split, a comma-separated list of package names, and a reason (an issue URL or similar).&lt;/p&gt;

&lt;p&gt;Before anything is split, the workflow enforces a few gates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Only admin/maintain/write collaborators can dispatch the split&lt;/span&gt;
&lt;span class="nv"&gt;permission&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPOSITORY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/collaborators/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACTOR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/permission"&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.permission'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;permission&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;admin|maintain|write&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACTOR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cannot dispatch split-main."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also refuses a stale or unmerged SHA, requiring the requested commit to match &lt;code&gt;origin/main&lt;/code&gt; exactly, and it blocks until the Quality workflow (PHPUnit, PHPStan, php-cs-fixer, Composer audit) has reported success for that same SHA. Only then does it split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;split_sha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;splitsh-lite &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOCAL_PREFIX&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git push &lt;span class="nb"&gt;split&lt;/span&gt; &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"refs/heads/main:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;existing&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;split_sha&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:refs/heads/main"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/splitsh/lite" rel="noopener noreferrer"&gt;splitsh-lite&lt;/a&gt; extracts the git history for one subdirectory (&lt;code&gt;packages/core&lt;/code&gt;, &lt;code&gt;packages/identity&lt;/code&gt;) into its own tree, preserving history instead of squashing it. The workflow pushes that exact commit to the split repo's &lt;code&gt;main&lt;/code&gt; branch, then verifies with &lt;code&gt;git ls-remote&lt;/code&gt; that what landed matches what was pushed, and uploads a provenance JSON artifact recording the source SHA, prefix, split SHA, actor, and reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Development Main Only, No Releases Yet
&lt;/h2&gt;

&lt;p&gt;The workflow can update &lt;code&gt;main&lt;/code&gt; on &lt;code&gt;anokii-core&lt;/code&gt; and &lt;code&gt;anokii-identity&lt;/code&gt;, but it cannot create tags, GitHub releases, or Packagist releases. That's deliberate: the split-main job proves the extraction is clean and history-preserving without committing to a versioning scheme before one exists. Tagged releases stay a separate, manual, exact-main procedure. Anyone consuming the split repos today does so at &lt;code&gt;dev-main&lt;/code&gt;, the same version constraint the root project itself uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Split Buys You
&lt;/h2&gt;

&lt;p&gt;Splitting this way doesn't just move files around, it enforces a boundary. Every returned identity entity now passes through one canonical access handler instead of whatever each duplicate implementation happened to do. The root distribution's test suite, PHPStan at max level, and a Composer audit all have to pass on the exact commit before that commit is eligible to split, so the packages that ship are never ahead of what was verified.&lt;/p&gt;

&lt;p&gt;If you're running a monorepo that needs to publish more than one standalone package, the pattern is worth stealing even without Anokii's specific tooling: keep authoring in one place with Composer path repositories for fast local iteration, gate any split on your CI status for that exact commit, and use &lt;code&gt;splitsh-lite&lt;/code&gt; to push history-preserving mirrors instead of hand-copying files into separate repos.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>architecture</category>
      <category>composer</category>
    </item>
    <item>
      <title>Two months of senior engineering in 36 AI-assisted hours</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Fri, 21 Aug 2026 04:15:19 +0000</pubDate>
      <link>https://dev.to/jonesrussell/one-ai-assisted-day-two-months-of-senior-engineering-33jb</link>
      <guid>https://dev.to/jonesrussell/one-ai-assisted-day-two-months-of-senior-engineering-33jb</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Over roughly &lt;strong&gt;36 hours&lt;/strong&gt;, a large, interconnected set of Waaseyaa Framework and Sheg work went from nothing to a clean landing sequence. The interesting part is how the time split, and what it would have cost the conventional way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two phases, not one
&lt;/h2&gt;

&lt;p&gt;The window had two distinct halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~10 hours of generation.&lt;/strong&gt; An autonomous ChatGPT session pursued a goal and opened a stack of pull requests across both the Framework and Sheg. That stack &lt;em&gt;was&lt;/em&gt; the backlog. It did not exist beforehand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~26 hours of integration.&lt;/strong&gt; The rest went to merging, rebasing, and repairing that stack into a moving &lt;code&gt;main&lt;/code&gt;, plus the defects that only surface when you land code instead of just writing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this is not a story about inheriting a mess and cleaning it up. The same window created the work and landed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What landed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;14 pull requests merged&lt;/strong&gt; — twelve in the Framework, two in Sheg.&lt;/p&gt;

&lt;p&gt;The Framework twelve covered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SQLite schema authority and migration safety&lt;/li&gt;
&lt;li&gt;Admin Surface serialization and filtered destinations&lt;/li&gt;
&lt;li&gt;Safe embed lifecycle and workflow transitions&lt;/li&gt;
&lt;li&gt;Save-advisory contracts and acknowledgements&lt;/li&gt;
&lt;li&gt;Entity revision recovery and authorization&lt;/li&gt;
&lt;li&gt;Canonical sitemap URLs&lt;/li&gt;
&lt;li&gt;Upgrade deadlock and timeout protection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two Sheg PRs hardened &lt;strong&gt;secure local-acceptance diagnostics&lt;/strong&gt; and fixed a &lt;strong&gt;Python test-wrapper that was reporting the wrong outcome&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defects the merge count hides
&lt;/h2&gt;

&lt;p&gt;Merges are the visible number. The real work was the defects that survived their own original implementations and only surfaced on the second pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate embed protocol messages&lt;/li&gt;
&lt;li&gt;Missing workflow refresh signals&lt;/li&gt;
&lt;li&gt;A historical-revision &lt;strong&gt;authorization gap&lt;/strong&gt; and a restore-field &lt;strong&gt;authorization bypass&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Advisory exceptions leaking across abstraction layers&lt;/li&gt;
&lt;li&gt;Swallowed acceptance-test failures, a green check on a red result&lt;/li&gt;
&lt;li&gt;A Composer subprocess deadlocking on a pipe&lt;/li&gt;
&lt;li&gt;Unsafe candidate-build caching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each was wired shut with regression, integration, and transport coverage so it stays shut. Underneath it all sits a &lt;strong&gt;358-site SQLite coupling inventory across 108 files&lt;/strong&gt;, the real map of what PostgreSQL and MySQL support will cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not happen
&lt;/h2&gt;

&lt;p&gt;No tag. No package release. No deployment. No production mutation.&lt;/p&gt;

&lt;p&gt;The release line held on purpose. The coordinated Framework and Sheg candidates wait until the CI lane and the advisory integration are finished. Anokii got compatibility and installation-contract testing but no direct change: protected, not touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conventional estimate
&lt;/h2&gt;

&lt;p&gt;Hand this to one experienced senior developer, generation and integration both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best estimate:&lt;/strong&gt; about &lt;strong&gt;8 full-time weeks&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Range:&lt;/strong&gt; &lt;strong&gt;6 to 10 weeks&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effort:&lt;/strong&gt; roughly &lt;strong&gt;240 to 400 hours&lt;/strong&gt;, midpoint around &lt;strong&gt;320&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two seniors might compress the calendar to three to five weeks, but the total labor barely moves.&lt;/p&gt;

&lt;p&gt;So the comparison is not 36 hours against building from scratch. It is one coordinated AI-assisted window against roughly two months of conventional senior engineering: generation, review, remediation, rebasing, testing, integration, and merge governance.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>aiassisteddevelopment</category>
      <category>buildinpublic</category>
      <category>waaseyaa</category>
      <category>engineeringvelocity</category>
    </item>
    <item>
      <title>The pipeline that writes this blog</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 16 Aug 2026 21:44:02 +0000</pubDate>
      <link>https://dev.to/jonesrussell/the-pipeline-that-writes-this-blog-5h9j</link>
      <guid>https://dev.to/jonesrussell/the-pipeline-that-writes-this-blog-5h9j</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;This post is a little unusual: it was drafted by the same pipeline it describes. This blog runs a GitHub Actions workflow, &lt;a href="https://github.com/jonesrussell/blog/blob/main/.github/workflows/content-autopilot.yml" rel="noopener noreferrer"&gt;Content Autopilot&lt;/a&gt;, that mines project activity, curates it with &lt;a href="https://claude.com/product/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; running headless in CI, writes a Hugo post, and publishes it to &lt;code&gt;main&lt;/code&gt; without a human in the loop. This post covers how the throttle, the curation gate, and the publish flow fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The throttle: Buffer queue depth
&lt;/h2&gt;

&lt;p&gt;Zero-touch pipelines need a rate limiter or they'll flood every channel the moment there's something to say. Instead of a fixed schedule, this one throttles on distribution capacity: Buffer's free tier caps a channel at 10 scheduled posts, so &lt;code&gt;scripts/buffer-queue-depth.mjs&lt;/code&gt; checks each channel's queue depth before anything else runs.&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;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;near&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasNext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;CAP&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;near&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;atCap&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any channel is at or near the cap, the script exits non-zero and the workflow stops before spending a single Claude token. Quiet weeks produce less; weeks where Buffer drains fast produce more. The queue itself decides the pace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate: an LLM judging its own inputs
&lt;/h2&gt;

&lt;p&gt;Once there's room to publish, the workflow pulls open &lt;code&gt;stage:mined&lt;/code&gt; issues from a separate tracking repo — one issue per interesting thing that happened across other projects. Not every mined item is worth a post. A lot of them are per-directory scaffolding noise.&lt;/p&gt;

&lt;p&gt;Rather than hand-curate, the workflow hands the candidate list to Claude with an explicit bar to clear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STEP 1 - CURATE (be ruthless). Pick AT MOST ONE candidate that would make
a genuinely useful, self-contained post a reader would value. Reject thin
per-directory dev-scaffolding items (e.g. "packages/field work"). If
NOTHING clears that bar, write {"status":"NONE","reason":"..."} to
autopilot-result.json and STOP.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing qualifies, the run ends with no post and no distribution. The gate is "good enough to publish," not "something to publish" — a distinction that matters once nobody is reading the draft before it goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify before you invent
&lt;/h2&gt;

&lt;p&gt;The riskiest part of an unsupervised pipeline is a model confidently inventing a function signature or a config flag that doesn't exist. The prompt closes that door explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STEP 2 - VERIFY. If the post would contain code, config, or interface
signatures, verify each against the real source repo using:
gh api repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/contents/&amp;lt;path&amp;gt; (GH_TOKEN is set). If you
cannot verify a snippet, DO NOT invent it. Either write the post without
it, or if the post depends on it write
{"status":"HOLD","issue":N,"reason":"unverifiable code"} to
autopilot-result.json and STOP.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude reads the actual source file through the GitHub API before quoting it. If it can't verify something the post depends on, it holds rather than guesses. This is the same rule the project's &lt;code&gt;CLAUDE.md&lt;/code&gt; already enforces for human-written posts — the autopilot just has to follow it without anyone checking its homework afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build gate, then publish
&lt;/h2&gt;

&lt;p&gt;Once Claude writes the post and a companion &lt;code&gt;docs/social/&amp;lt;slug&amp;gt;.md&lt;/code&gt; file, the workflow runs &lt;code&gt;hugo --gc --minify&lt;/code&gt;. If the build fails, the post never gets committed — a broken page bundle or bad frontmatter simply stops the run. Only after a clean build does the workflow commit, &lt;code&gt;git pull --rebase&lt;/code&gt;, and push to &lt;code&gt;main&lt;/code&gt;, which triggers the existing deploy workflow.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build gate&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.result.outputs.status == '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;hugo --gc --minify&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;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.result.outputs.status == 'PUBLISH' &amp;amp;&amp;amp; github.event.inputs.dry_run != 'true'&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;git add "${{ steps.result.outputs.post_path }}" "${{ steps.result.outputs.social_path }}" data/autopilot-ledger.json&lt;/span&gt;
    &lt;span class="s"&gt;git commit -m "content: autopilot publish ${{ steps.result.outputs.slug }} (queue #${{ steps.result.outputs.issue }})"&lt;/span&gt;
    &lt;span class="s"&gt;git pull --rebase origin main&lt;/span&gt;
    &lt;span class="s"&gt;git push origin main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JSON ledger (&lt;code&gt;data/autopilot-ledger.json&lt;/code&gt;) records which mined issue numbers have already been processed, so a rerun never republishes the same source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distribute, then close the loop
&lt;/h2&gt;

&lt;p&gt;The last step reads the same &lt;code&gt;docs/social/&amp;lt;slug&amp;gt;.md&lt;/code&gt; file and queues one post per platform through Buffer's GraphQL API:&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="c1"&gt;// Parse "## Platform" sections out of the markdown.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;md&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^##&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t]&lt;/span&gt;&lt;span class="sr"&gt;+/m&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;1&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;nl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;part&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="nx"&gt;nl&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;parsed&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;part&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="nx"&gt;nl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;Each platform's body is queued with &lt;code&gt;createPost&lt;/code&gt;, and Bluesky posts over 300 characters are rejected before they ever reach the API. The originating tracking issue is then closed, so the same idea can't get mined and drafted twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What keeps it honest
&lt;/h2&gt;

&lt;p&gt;Nothing here is exotic on its own — a cron trigger, an LLM call, a build step, a git push. What makes it safe to run unsupervised is that every stage can say no: the throttle can refuse to run, the curator can refuse to pick anything, the verifier can refuse to invent code, and the build gate can refuse a broken post. Zero-touch doesn't mean zero judgment; it means the judgment has to be encoded into the pipeline instead of applied by a person reading a draft.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>claude</category>
      <category>automation</category>
      <category>hugo</category>
    </item>
    <item>
      <title>A beginner's guide to Git worktrees: What they are, why they matter, and how to use them without breaking anything</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 16 Jul 2026 20:47:15 +0000</pubDate>
      <link>https://dev.to/jonesrussell/a-beginners-guide-to-git-worktrees-what-they-are-why-they-matter-and-how-to-use-them-without-3hb3</link>
      <guid>https://dev.to/jonesrussell/a-beginners-guide-to-git-worktrees-what-they-are-why-they-matter-and-how-to-use-them-without-3hb3</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;If you have ever needed to work on two branches at the same time, Git worktrees can save you a lot of friction. This post covers what worktrees are, why they exist, and how you can use them safely as a beginner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You have &lt;a href="https://git-scm.com/" rel="noopener noreferrer"&gt;Git&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;You can run commands in your terminal&lt;/li&gt;
&lt;li&gt;You already have a local repository&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is a Git Worktree?
&lt;/h2&gt;

&lt;p&gt;A Git worktree is an extra working folder connected to the same repository history.&lt;br&gt;&lt;br&gt;
You can think of it as another checkout of your project, without making another full clone.&lt;/p&gt;

&lt;p&gt;Your main folder still exists. A worktree gives you a second folder where a different branch can be checked out at the same time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Worktrees Exist
&lt;/h2&gt;

&lt;p&gt;Git worktrees solve a practical problem. You may be in the middle of feature work, then need to fix a bug on another branch right away.&lt;/p&gt;

&lt;p&gt;Without worktrees, you usually do one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stash or commit unfinished work, then switch branches&lt;/li&gt;
&lt;li&gt;Open a second full clone of the same repository&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both options work, but both add overhead. Worktrees give you a cleaner path.&lt;/p&gt;
&lt;h2&gt;
  
  
  Normal Clone vs Branch Checkout vs Worktree
&lt;/h2&gt;

&lt;p&gt;Here is the simple difference:&lt;/p&gt;
&lt;h3&gt;
  
  
  Normal clone
&lt;/h3&gt;

&lt;p&gt;A clone is a separate copy of a repository with its own &lt;code&gt;.git&lt;/code&gt; directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/example/project.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You use this when you need the repository on your machine for the first time. It is fully independent from other clones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Branch checkout
&lt;/h3&gt;

&lt;p&gt;A branch checkout changes which branch is active in your current folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout feature/foo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fast, but only one branch can be active in that folder at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worktree
&lt;/h3&gt;

&lt;p&gt;A worktree creates another folder tied to the same repository, usually on a different branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../feature-foo feature/foo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have two folders open at once: your main folder and &lt;code&gt;../feature-foo&lt;/code&gt;. Each can point at a different branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Benefits of Worktrees
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Multiple branches checked out at once
&lt;/h3&gt;

&lt;p&gt;You can keep your main branch open in one folder and your feature branch in another.&lt;br&gt;&lt;br&gt;
No constant branch switching.&lt;/p&gt;
&lt;h3&gt;
  
  
  2) Isolated environments for experiments
&lt;/h3&gt;

&lt;p&gt;You can test risky changes in one worktree without touching the working state in another folder.&lt;/p&gt;
&lt;h3&gt;
  
  
  3) No need for multiple full clones
&lt;/h3&gt;

&lt;p&gt;Worktrees share repository data, so you avoid duplicate clones for everyday branch work.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Safe Beginner Workflow
&lt;/h2&gt;

&lt;p&gt;This is a clean workflow you can use right away.&lt;/p&gt;
&lt;h3&gt;
  
  
  1) Create a worktree
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../feature-foo feature/foo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This command creates a new folder named &lt;code&gt;../feature-foo&lt;/code&gt; and checks out &lt;code&gt;feature/foo&lt;/code&gt; there.&lt;br&gt;&lt;br&gt;
If &lt;code&gt;feature/foo&lt;/code&gt; does not exist yet, create it first with &lt;code&gt;git branch feature/foo&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  2) Switch into it
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../feature-foo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now every Git command runs inside that worktree folder.&lt;br&gt;&lt;br&gt;
Before you edit files, confirm where you are with &lt;code&gt;pwd&lt;/code&gt; and &lt;code&gt;git branch --show-current&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  3) Commit from it
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add first pass of feature foo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;These commits belong to the branch checked out in that worktree.&lt;br&gt;&lt;br&gt;
You do not need to return to your original folder to commit.&lt;/p&gt;
&lt;h3&gt;
  
  
  4) Remove it safely
&lt;/h3&gt;

&lt;p&gt;First leave the worktree folder, then remove it with Git.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../your-main-repo
git worktree remove ../feature-foo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Git to unregister the worktree and remove the directory safely.&lt;br&gt;&lt;br&gt;
Always prefer this over deleting the folder manually.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to See Your Current Worktrees
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This shows every registered worktree path and branch.&lt;br&gt;&lt;br&gt;
Use this often, especially when you are learning.&lt;/p&gt;
&lt;h2&gt;
  
  
  Common Beginner Mistakes
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Deleting the directory without removing the worktree
&lt;/h3&gt;

&lt;p&gt;If you run &lt;code&gt;rm -rf&lt;/code&gt; on a worktree folder first, Git can keep stale metadata.&lt;br&gt;&lt;br&gt;
Remove worktrees with &lt;code&gt;git worktree remove &amp;lt;path&amp;gt;&lt;/code&gt; whenever possible.&lt;/p&gt;
&lt;h3&gt;
  
  
  Forgetting which worktree you are in
&lt;/h3&gt;

&lt;p&gt;It is easy to commit to the wrong branch when two folders look similar.&lt;br&gt;&lt;br&gt;
Check &lt;code&gt;pwd&lt;/code&gt; and &lt;code&gt;git branch --show-current&lt;/code&gt; before making changes.&lt;/p&gt;
&lt;h3&gt;
  
  
  Trying to check out the same branch twice
&lt;/h3&gt;

&lt;p&gt;Git does not allow the same branch to be active in two worktrees at once.&lt;br&gt;&lt;br&gt;
Create a new branch if you need a second experimental space.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Simple Mental Model for &lt;code&gt;.git/worktrees&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Your main repository still has the real Git database.&lt;br&gt;&lt;br&gt;
Inside it, &lt;code&gt;.git/worktrees&lt;/code&gt; stores small records that point to each extra working folder.&lt;/p&gt;

&lt;p&gt;Think of it like a clipboard that says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which extra folders exist&lt;/li&gt;
&lt;li&gt;Which branch each one uses&lt;/li&gt;
&lt;li&gt;Whether Git still expects them to be present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why manual deletion can confuse Git. The clipboard still has an entry, even if the folder is gone.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to Clean Up Orphaned Worktrees
&lt;/h2&gt;

&lt;p&gt;Sometimes a worktree folder gets deleted outside Git.&lt;br&gt;&lt;br&gt;
You can clean this up safely in a few steps.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1: List what Git thinks exists
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Look for paths that no longer exist on disk.&lt;br&gt;&lt;br&gt;
Those are likely orphans.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Prune stale metadata
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree prune
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This removes stale worktree entries that no longer point to valid folders.&lt;br&gt;&lt;br&gt;
It is a safe maintenance command for this situation.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Verify cleanup
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run the list command again to confirm orphan entries are gone.&lt;br&gt;&lt;br&gt;
If everything looks clean, you are done.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verify It Works
&lt;/h2&gt;

&lt;p&gt;Run this quick check whenever you start using worktrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree list
git branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;
&lt;span class="nb"&gt;pwd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These three commands tell you what worktrees exist, which branch is active, and which folder you are in.&lt;br&gt;&lt;br&gt;
That simple habit prevents most beginner mistakes.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>git</category>
      <category>gitworktree</category>
      <category>versioncontrol</category>
      <category>beginnerguide</category>
    </item>
    <item>
      <title>The hackathon Anthropic didn't expect</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 16 Jul 2026 20:47:11 +0000</pubDate>
      <link>https://dev.to/jonesrussell/the-hackathon-anthropic-didnt-expect-2hp1</link>
      <guid>https://dev.to/jonesrussell/the-hackathon-anthropic-didnt-expect-2hp1</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.platformer.news/boris-cherny-interview-ai-jobs/" rel="noopener noreferrer"&gt;Casey Newton interviewed Boris Cherny&lt;/a&gt;, the creator of Claude Code, for Platformer last week. Most of the coverage pulled the headline-friendly quote that coding is "solved" and moved on. I want to point at a smaller moment in the same interview that I think matters more. This post is about that moment, and three things I've watched up close that line up with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hackathon Anthropic didn't expect
&lt;/h2&gt;

&lt;p&gt;Newton asks Cherny about the AI divide. The worry, in shorthand, is that the people who already have power will use these tools to get more of it. The early data on who benefits from new technology usually goes that way. So who's actually getting the most out of Claude Code?&lt;/p&gt;

&lt;p&gt;Cherny's answer surprised Newton, and it surprised me when I read it. He talks about a recent Anthropic hackathon for the Opus 4.7 release, and says the people who won were largely not professional engineers. "There was an electrician, a doctor, a carpenter who used it to build an app." Same pattern at the 4.6 hackathon. He calls it a continuous surprise. The people who get the most value out of Claude Code, he says, are not the people he'd expect.&lt;/p&gt;

&lt;p&gt;That's a small line in a long interview. It's the line I haven't stopped thinking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions, not one
&lt;/h2&gt;

&lt;p&gt;The AI-jobs discourse is mostly fighting over one question: will engineers be replaced. Cherny's answer to that question, if you read the whole interview, is actually mild. He thinks the title "software engineer" probably changes ("builder" is the word he uses), the role expands, and the number of people writing code with the help of agents goes up roughly a hundred-fold. That's not an extinction story. It's a transformation story, with a hiring pull, not a layoff cliff.&lt;/p&gt;

&lt;p&gt;But that's not the only question we should be asking. The other question is the one Newton was getting at: when the syntax gate falls, who walks through?&lt;/p&gt;

&lt;p&gt;The hackathon detail is an early answer. Not "the engineers got faster." An electrician, a doctor, and a carpenter built apps that won. The default story we tell about new technology — the people in the room get more powerful first — doesn't fit. The people in the room didn't even win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I've watched up close
&lt;/h2&gt;

&lt;p&gt;Here's what I'm seeing in three different places.&lt;/p&gt;

&lt;p&gt;I just finished my Grade 12 coursework as an adult learner through Sagamok Anishnawbek's Lifelong Learning Centre. AI was in the loop for a lot of it. I'm not the demographic anyone meant when they said "AI for developers." I'm a forty-something who came back to finish his OSSD, and the tools worked for me anyway. I finished the work that for years had been hard to finish. That's one shape.&lt;/p&gt;

&lt;p&gt;I started &lt;a href="https://www.change.org/p/rainbow-district-school-board-give-students-a-clear-fair-ai-policy-before-next-semester" rel="noopener noreferrer"&gt;a petition&lt;/a&gt; asking the Rainbow District School Board to publish a real public AI policy before next semester. Students across the board are using these tools every day. The adults setting the rules haven't caught up. Partway through my own coursework, I was pulled aside and questioned about my AI use because there was no policy to point at. The petition is about that, but it's also about the kids: they're walking through a door before anyone has written a rule about it. That's another shape.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://oiatc.ca" rel="noopener noreferrer"&gt;Anokii&lt;/a&gt;, the embedded chat on the OIATC site, on top of my own framework. Anokii isn't being built for engineers. It's being built so a member of a community can find a community-specific resource and get an answer that cites where it came from. Per-community variants. A relevance gate so it stays quiet when it shouldn't speak. A topic-confidence gate on the citations. When the tool is reachable, and built with the people who'll use it in mind, the people who reach for it are not the obvious ones. That's a third shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Three different angles. An adult learner finishing high school. A petition asking for a fair rule. A chat that surfaces community resources without pretending to be smarter than it is.&lt;/p&gt;

&lt;p&gt;None of these are "engineers replaced." All of them are "someone got access to a tool that wasn't available before, and the question is whether the systems around them caught up."&lt;/p&gt;

&lt;p&gt;Cherny's hackathon detail isn't an outlier. It's the leading edge of what happens everywhere when the syntax-and-credentials gate falls. People who were never in the room start showing up. The electrician builds the app. The adult learner finishes the coursework. The community member finds the resource they needed and gets a real citation instead of an authoritative-sounding guess.&lt;/p&gt;

&lt;p&gt;The energy we spend arguing about whether engineers will exist in five years is energy we are not spending on what the hackathon detail is actually telling us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the energy belongs
&lt;/h2&gt;

&lt;p&gt;Three places I'd want it to go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schools need policy now, not after the next semester.&lt;/strong&gt; The Rainbow District petition is one example. Every board with students using these tools is facing the same problem. The students are already there. The policy can't be retroactive forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Communities deserve tools built for them, not demos pointed at them.&lt;/strong&gt; Anokii is the version of that I'm running, and the way to tell whether a community tool is real is whether it cites its sources, whether it knows when to stay quiet, and whether it gets better when the community pushes back on it. Demos do not pass any of those tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For engineers and builders and whatever else we get called next:&lt;/strong&gt; stop pretending the threat is your job disappearing. The threat, if there is one, is that the tools become most useful to the people who already had power. Cherny's data so far says that is not what's happening. Don't make it happen. (If you want the engineer-facing half of this argument, I wrote &lt;a href="https://jonesrussell.github.io/blog/from-vibe-coded-to-shippable/" rel="noopener noreferrer"&gt;the playbook for taking a vibe-coded prototype to something a stranger can run&lt;/a&gt; earlier this week. This post is the other half.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The people getting the most out of these tools right now are not the people the discourse expects. The electrician winning the hackathon, the adult learner finishing the diploma, the community member finding the resource. None of them are the story we're telling. All of them are the story we should be telling.&lt;/p&gt;

&lt;p&gt;That should change how we talk about this.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>aiassisteddev</category>
      <category>equity</category>
      <category>community</category>
      <category>policy</category>
    </item>
    <item>
      <title>From vibe-coded to shippable: a playbook</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 16 Jul 2026 20:46:37 +0000</pubDate>
      <link>https://dev.to/jonesrussell/from-vibe-coded-to-shippable-a-playbook-j8i</link>
      <guid>https://dev.to/jonesrussell/from-vibe-coded-to-shippable-a-playbook-j8i</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;There's a lot of energy right now spent bashing vibe coding. I think most of it is aimed at the wrong target. The MVP you stand up with AI doesn't have to generalize. That's not what it was for. The interesting craft is what you do &lt;em&gt;next&lt;/em&gt; to take that prototype from "it works on my laptop" to something a stranger can run, debug, and trust. That's a craft worth respecting. This post is the playbook I'm currently running on a real public repo, with six specific moves and an artifact for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong fight
&lt;/h2&gt;

&lt;p&gt;The bashing usually goes like this: "Look at this AI-generated MVP, look at how brittle it is, look at how much it cost in tokens to get it half-right." Sure. Now compare it to the alternative: an empty directory and a developer who hasn't started. The point of the prototype isn't to be production code. The point of the prototype is to find out whether the idea works at all. To answer a question. To make something move on a screen that wasn't moving yesterday.&lt;/p&gt;

&lt;p&gt;Once it moves, you have new information. You know which parts are load-bearing. You know which gotchas bit you. You know which assumptions held up. You're now in a much better position to do the second pass, and the second pass is where the craft lives. The craft is the set of moves you make to take a working prototype to a state where someone else can run it, debug it, and trust it.&lt;/p&gt;

&lt;p&gt;Those moves are not mysterious. They have names. They are the rest of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo on the bench
&lt;/h2&gt;

&lt;p&gt;The worked example is the OIATC application, a public PHP repo I build alongside my own modern PHP framework, &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/oiatc-waaseyaa" rel="noopener noreferrer"&gt;https://github.com/waaseyaa/oiatc-waaseyaa&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It powers &lt;a href="https://oiatc.ca" rel="noopener noreferrer"&gt;oiatc.ca&lt;/a&gt;. The marquee feature inside it is Anokii, an embedded AI chat that grounds its answers in the site's own community-resource pages and cites them. Anokii started as a vibe-coded chat experiment. It's now a RAG pipeline with a relevance gate, per-community variants, anonymous query-gap logging, and a topic-confidence gate on citations. Every step below points at a specific artifact in that repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1. Milestone the roadmap
&lt;/h2&gt;

&lt;p&gt;The first thing that turned the Anokii work from "a directory of chat hacks" into "a project" was naming the phases. Phase 1 got a chat working at all. Phase 2 introduced RAG grounding, split into stages: Stage 1 was the &lt;code&gt;doc_chunk&lt;/code&gt; entity and the &lt;code&gt;app:ingest-docs&lt;/code&gt; CLI that fills it; Stage 2 was the keyword-RAG retrieval over those chunks.&lt;/p&gt;

&lt;p&gt;Branches in the repo carry the phase names. &lt;code&gt;feat/doc-chunk-ingestion&lt;/code&gt; landed Stage 1. &lt;code&gt;feat/keyword-rag-chat&lt;/code&gt; landed the MVP retrieval. &lt;code&gt;feat/sagamok-resources&lt;/code&gt; and &lt;code&gt;feat/data-sovereignty-and-masthead&lt;/code&gt; carried sibling work. Each branch closes with a merge commit that documents what shipped, and only after that does the next phase begin.&lt;/p&gt;

&lt;p&gt;Three phases, a handful of branches, every one of them with a concrete definition of done. That's enough structure to know what to work on next, and just as importantly, what &lt;em&gt;not&lt;/em&gt; to work on right now.&lt;/p&gt;

&lt;p&gt;You don't need a wiki or a project board. You need branch names that announce intent and merge commits that close the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2. Write the runbook, including the gotchas
&lt;/h2&gt;

&lt;p&gt;The repo's &lt;a href="https://github.com/waaseyaa/oiatc-waaseyaa/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;CLAUDE.md&lt;/a&gt; is the runbook. It opens with a Strategy folder pointer: a separate workspace outside the repo tracks every live page on oiatc.ca against its canonical Twig source, last-updated date, and analytics. The CLAUDE.md says, plainly, that if the two disagree, the repo wins.&lt;/p&gt;

&lt;p&gt;Below that pointer it documents the architecture (&lt;code&gt;Access/&lt;/code&gt;, &lt;code&gt;Controller/&lt;/code&gt;, &lt;code&gt;Domain/&lt;/code&gt;, &lt;code&gt;Entity/&lt;/code&gt;, &lt;code&gt;Provider/&lt;/code&gt;, &lt;code&gt;Support/&lt;/code&gt;), the ServiceProvider DI methods with full signatures (&lt;code&gt;singleton&lt;/code&gt;, &lt;code&gt;bind&lt;/code&gt;, &lt;code&gt;resolve&lt;/code&gt;, &lt;code&gt;tag&lt;/code&gt;, &lt;code&gt;entityType&lt;/code&gt;), the queue Job pattern with &lt;code&gt;tries&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;retryAfter&lt;/code&gt;, and the frontend template families (site shell vs. longform documents vs. news). An Operations section, added in commit &lt;code&gt;896ec8f&lt;/code&gt;, covers deploy, Raspberry Pi access, and secrets.&lt;/p&gt;

&lt;p&gt;The runbook is the operating manual you wish someone had handed you. The OIATC one is dense because it earned every line. The mistakes that produced those lines aren't generic; they're specific to this app, this framework version, this deployment target. That's exactly what makes them worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3. Pin the world
&lt;/h2&gt;

&lt;p&gt;Vibe-coded prototypes love &lt;code&gt;composer install&lt;/code&gt; and &lt;code&gt;git clone main&lt;/code&gt;. That works on Tuesday. It breaks on Friday when upstream cuts a release. The OIATC repo pins the world three different ways.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;composer.lock&lt;/code&gt; is committed and treated as the source of truth. Lock-file drift gets its own entry in the upstream-notes (entry 003 walks through a drift caused by a post-hash &lt;code&gt;php: &amp;gt;=8.5&lt;/code&gt; constraint and the fix). Second, the deploy contract is a &lt;code&gt;docker compose run&lt;/code&gt; that calls &lt;code&gt;bin/waaseyaa db:init&lt;/code&gt; before bringing the app up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; oiatc-app bin/waaseyaa db:init
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;db:init&lt;/code&gt; is idempotent. Fresh volume gets migrations. Current schema is a no-op. Safe to invoke on every deploy. That's what makes a deploy step a contract instead of a tradition.&lt;/p&gt;

&lt;p&gt;Third, the app lives on a specific &lt;code&gt;waaseyaa/framework&lt;/code&gt; alpha version (currently alpha.188). Upgrades happen as deliberate events with their own branch and their own entry in the upstream-notes, not as quiet drift. When upstream cuts a release that breaks something, you have a working baseline to compare against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4. Name the graveyard
&lt;/h2&gt;

&lt;p&gt;The repo has a literal graveyard directory: &lt;a href="https://github.com/waaseyaa/oiatc-waaseyaa/tree/main/docs/archive" rel="noopener noreferrer"&gt;&lt;code&gt;docs/archive/2026-04-20-cut-pages/&lt;/code&gt;&lt;/a&gt;. It holds Twig templates and design notes for pages that were on oiatc.ca and got cut. Not deleted from git history. Not pretended to never have existed. Filed under a date and a reason so the next person can read what we tried and why we stopped.&lt;/p&gt;

&lt;p&gt;This pattern shows up in the codebase too. The keyword-RAG retrieval merge commit literally calls itself "Path B." There was a Path A. Path A's notes are still around. Anyone looking at the current Path B implementation can see the alternative that was considered and the trade-offs that drove the decision.&lt;/p&gt;

&lt;p&gt;Naming the graveyard is one of the cheapest, most under-used moves in software. Most repos don't do it because it feels embarrassing. The OIATC repo does it because each cut page and each abandoned path represents a hypothesis tested. The next person who has a similar hypothesis deserves to see the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5. Recon before you build
&lt;/h2&gt;

&lt;p&gt;The single most useful artifact in the repo is &lt;a href="https://github.com/waaseyaa/oiatc-waaseyaa/blob/main/docs/waaseyaa-upstream-notes.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/waaseyaa-upstream-notes.md&lt;/code&gt;&lt;/a&gt;. It's a running log of framework quirks, bugs, breakages, and missing pieces hit while building on an alpha release of &lt;code&gt;waaseyaa/framework&lt;/code&gt;. Each entry uses a fixed format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## NNN — short title&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Date / version:**&lt;/span&gt; YYYY-MM-DD · waaseyaa/framework alpha.NNN
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing:**&lt;/span&gt; what we were doing when we hit it
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Symptom:**&lt;/span&gt; the observable problem
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Workaround:**&lt;/span&gt; what we did to get unblocked
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Likely upstream fix:**&lt;/span&gt; the proper change in waaseyaa/framework
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 16+ entries. Stale &lt;code&gt;VERSION&lt;/code&gt; files. Ambiguous class resolution between the metapackage and split mirrors. Lock-file drift after a platform requirement bump. Hard &lt;code&gt;ext-sodium&lt;/code&gt; dependency via the OIDC stack. Each one is recon for the framework itself.&lt;/p&gt;

&lt;p&gt;The point of the upstream-notes is not to complain. The point is to keep app-level hacks out of the consumer code. Every entry is a decision: do we patch around it here, do we file the upstream fix now, do we wait until the next alpha. Without the log, those decisions get re-litigated every time someone hits the same wall.&lt;/p&gt;

&lt;p&gt;This is also the move I think AI tooling makes most useful. When you hit a quirk, write the entry first. The structured shape forces you to articulate what you actually saw, what you're guessing, and what would fix it upstream. That writing is exactly the input the model needs to either help you work around it cleanly or propose an upstream change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6. Layer specs on what's next
&lt;/h2&gt;

&lt;p&gt;The hardest current work on Anokii doesn't get vibe-coded. The topic-confidence gate that decides whether a citation is worth showing (&lt;code&gt;65de562&lt;/code&gt;), the relevance gate that ensures only genuinely relevant passages are cited (&lt;code&gt;8359e37&lt;/code&gt;), the climate-companion variant for the Massey Solar resource cluster (&lt;code&gt;c8956f6&lt;/code&gt;), the shared relational-graph instance per community (&lt;code&gt;928191d&lt;/code&gt;) — all of those run through &lt;a href="https://github.com/waaseyaa/oiatc-waaseyaa/tree/main/docs/superpowers" rel="noopener noreferrer"&gt;&lt;code&gt;docs/superpowers/specs&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;docs/superpowers/plans&lt;/code&gt;. Each change has a spec articulating the intent, a plan decomposing the work, and reviewable PRs landing the implementation.&lt;/p&gt;

&lt;p&gt;Why specs and not just commits: a relevance gate that filters citations is the kind of thing where a hallucinated implementation looks fine until a user gets a confidently-wrong answer. The spec gives you a contract for what the gate is supposed to do. The plan gives reviewers something to evaluate against. The implementation has a referenceable target.&lt;/p&gt;

&lt;p&gt;This is the handoff move. Vibe coding got the chat answering. The runbook captured the patterns. The pinned world made the deploys reproducible. The graveyard remembered the dead ends. The upstream-notes captured what the framework still owes us. And now spec-driven work takes over for the parts that are too big or too risky to vibe through. Each layer earned the right to the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like together
&lt;/h2&gt;

&lt;p&gt;The OIATC repo is not a clean codebase. It's a layered one. Some Anokii code still reads like the experiment it started as, because it still works and the cost of replacing it is higher than the cost of keeping it. The newer pieces — the keyword-RAG retrieval, the relevance gate, the topic-confidence gate — were built with specs and reviewed PRs because each of them is a place where a quiet bug becomes a confidently-wrong answer to a real person looking for real community resources.&lt;/p&gt;

&lt;p&gt;Vibe coding was the first move. The six moves above are the second, third, fourth, fifth, sixth, and seventh. None of them are mysterious. None of them are expensive. They are the boring craft that turns a working prototype into something a stranger can run, debug, and trust.&lt;/p&gt;

&lt;p&gt;If you've been bashing vibe coding, you're aiming at the start of a process and ignoring the rest of it. If you've been vibe-coding without any of the rest, you're going to keep losing days to the same gotchas, and the next person who touches your prototype is going to lose them too.&lt;/p&gt;

&lt;p&gt;There's no shame in vibe coding. There's a lot of value in what comes next.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>vibecoding</category>
      <category>aiassisteddev</category>
      <category>prototyping</category>
      <category>specdriven</category>
    </item>
    <item>
      <title>AI keeps speccing my projects on pre-AI timelines</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 16 Jul 2026 20:46:33 +0000</pubDate>
      <link>https://dev.to/jonesrussell/ai-keeps-speccing-my-projects-on-pre-ai-timelines-3205</link>
      <guid>https://dev.to/jonesrussell/ai-keeps-speccing-my-projects-on-pre-ai-timelines-3205</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;You sit down to spec a new feature with &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; or whichever assistant. You describe the surface, the constraints, the slices you want it broken into. The plan comes back: "5 work packages, 6 to 8 weeks." You shipped a 5-WP mission in a day yesterday. This post is about that gap and why it eats more time than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Spec mode is where it shows up worst. You ask for a plan. The plan arrives with timelines attached. Those timelines are calibrated to a world that ended around 2023. A "2-week task" inside a modern plan is often something you can ship the same afternoon if the scope is honest.&lt;/p&gt;

&lt;p&gt;When you push back, the assistant does not back down quickly. It cites "industry norms," talks about "code review cycles," reminds you about testing time and edge cases. All of which are real concerns. None of which describe what actually happens when you and an AI co-author the work at full session-pace.&lt;/p&gt;

&lt;p&gt;You end up in a loop. You spec. It estimates long. You explain that the work is being done with AI. It hedges. You re-spec. You ship in a fraction of the estimate. Next time you do this same dance again, because the assistant has no persistent sense that you ship faster than the baseline it was trained on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Almost every public dataset that taught these models how long things take pre-dates widespread AI-assisted development. Stack Overflow threads from 2019 about how long a Laravel package takes to build. Engineering manager blog posts from 2021 about ticket-pointing conventions. Git histories from teams that did not have Claude on the keyboard. The model sees those signals and projects.&lt;/p&gt;

&lt;p&gt;The reasoning models are smart enough to know AI assistance changes velocity. They will say so if you ask them directly. The problem is they do not apply that knowledge to estimation by default. You have to invoke it explicitly, every time, and even then they hedge in case you turn out to be slower than you said.&lt;/p&gt;

&lt;p&gt;It is the same failure mode as asking an assistant in early 2024 whether you should use React 18 features. It knows React 19 exists. It does not always update its working assumptions to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First, time. Every spec session has a recalibration tax. You say two weeks; it says six. You push; it says four. You re-explain; it says three. You ship in two days. Multiply across a year of speccing and you are spending real hours arguing with an estimator that is wrong in one direction.&lt;/p&gt;

&lt;p&gt;Second, drift. When you accept a long estimate to move on, the WP breakdown that gets generated is shaped by that estimate. Work packages get padded with imagined complexity to justify the duration. Tasks get split that did not need splitting. You end up implementing a plan designed for a slower world and discovering halfway through that half the WPs collapse into one.&lt;/p&gt;

&lt;p&gt;The downstream version of this is worse: an assistant reviewing your PR will sometimes flag "is the scope of this change too large for a single WP?" when the change is fine and the WP boundary was the artifact of a stale estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;The fixes are all small and all annoying because you have to repeat them.&lt;/p&gt;

&lt;p&gt;State the velocity assumption in your spec prompt explicitly. "This is being done with AI assistance. Estimate WPs in hours, not days, and assume each WP fits inside a single focused session." This is not subtle and it works. You can put it in your project's spec template so you do not have to remember.&lt;/p&gt;

&lt;p&gt;Reject the estimate during planning, not after. If the plan comes back with multi-week WPs, push back before generating the WP breakdown. The breakdown is downstream of the duration assumption; if you let it materialize, you inherit its shape.&lt;/p&gt;

&lt;p&gt;Cite recent evidence. "I shipped a 5-WP mission yesterday in a day. Use that as the baseline, not pre-2024 industry norms." The model responds to concrete recent counter-evidence more than to abstract argument.&lt;/p&gt;

&lt;p&gt;Strip duration estimates from the plan entirely when you can. Half the time you do not need them and they only exist because the spec template asked for them. A plan that says "5 WPs, dependency-ordered" is more honest than a plan that says "5 WPs, 6-8 weeks." You are not running a Gantt chart. You are sequencing work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper problem
&lt;/h2&gt;

&lt;p&gt;This will probably get worse before it gets better. Training data for "how long does this take" lags real-world velocity by a couple of years. Right now the gap is roughly 2023 vs 2026, which is about a 3-5x estimation error in many domains. As AI-assisted development accelerates, that gap widens, and the public-corpus signal stays stuck in the past.&lt;/p&gt;

&lt;p&gt;The assistants that handle this well will be ones that calibrate against the user's recent shipping pace, not against the corpus average. That requires per-user telemetry the current tools do not have, or explicit user-state that they do not yet persist between sessions. Until then, the workaround is the four-step dance above.&lt;/p&gt;

&lt;p&gt;The honest version of the complaint is not "AI is bad at estimates." It is "AI is estimating on a baseline that the existence of AI itself made obsolete." Worth naming so you can stop arguing with it and just override the defaults.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>vibecoding</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
