<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Dan Mercede</title>
<link>https://www.danmercede.com/</link>
<description>Essays and guides from Dan Mercede: turning AI from experiments into owned, governed workflows operators can actually run.</description>
<language>en</language>
<lastBuildDate>Sat, 11 Jul 2026 07:00:00 +0000</lastBuildDate>
<docs>https://www.rssboard.org/rss-specification</docs>
<atom:link href="https://www.danmercede.com/feed.xml" rel="self" type="application/rss+xml"/>
<atom:link href="https://pubsubhubbub.appspot.com/" rel="hub"/>
<item><title>The Recon Refuted the Task: Building Infrastructure With an Agent You Can Trust</title><link>https://www.danmercede.com/guides/agent-built-infrastructure-you-can-trust</link><guid isPermaLink="true">https://www.danmercede.com/guides/agent-built-infrastructure-you-can-trust</guid><pubDate>Sat, 11 Jul 2026 07:00:00 +0000</pubDate><description>I asked a coding agent to build a monitor for my self-hosted servers, and its first move was to prove I did not need one: five watchers already existed. This guide is the anatomy of the trust that let an agent ship production monitoring anyway, the five checkpoints where a wrong assumption gets caught by something other than the model&#39;s own judgment.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I asked an agent to build a monitor: something to watch the background jobs across my servers and tell me when one broke. It came back and told me not to build it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Not out of laziness. It had spent its first hour running probes against the live machines instead of writing code, and the probes said the thing I asked for already existed five times over. One platform watched the hosts. A container metrics collector watched the containers. The init system watched its own units. Two more scripts watched the edges. What I was actually missing was not another detector. It was the layer that reads five noisy signals and says &amp;quot;these three are the same incident, here is the likely cause, here is the runbook that fixes it.&amp;quot;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That refusal was the single most valuable thing the agent did on the whole project. A detector I did not need would have shipped in an afternoon, run green forever, and watched nothing that mattered. The hour of recon that killed it is the reason I trust what got built instead.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide is the anatomy of that trust. It is worth nothing that an agent seems careful. What matters is the specific, checkable structure that let this one build a piece of production monitoring I now depend on: a recon step that argues with the task, a finish line a model can actually verify, a plan I approved before any code existed, autonomy bounded by construction rather than by a polite prompt, and acceptance by firing every control instead of watching it sit green.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone pointing a coding agent at real infrastructure, especially self-hosted systems where a quiet failure hides for weeks and &amp;quot;it ran without erroring&amp;quot; is not the same as &amp;quot;it works.&amp;quot;&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;why-should-an-agent-s-first-job-be-to-refute-your-task&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why should an agent&amp;#x27;s first job be to refute your task?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;In a 2026-07 build, the recon phase refuted the premise of the task before a line of code existed. I had asked for a holistic watcher because &amp;quot;nothing watches the jobs holistically,&amp;quot; and a live sweep found five watchers already running. Four background units were failing at that exact moment, invisible because the watcher&amp;#x27;s coverage list was fixed at install time and never grew as new units shipped.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is the crown rule of pointing an agent at infrastructure: recon is not information-gathering, it is error-correction. A first draft of any plan is full of confident, wrong premises, because the person writing the task does not have the live state in front of them. The job of the recon step is to take each premise and try to break it against the real system. &amp;quot;Nothing watches this&amp;quot; breaks the instant you list what is watching it. &amp;quot;Add a host-metrics exporter to one of the nodes&amp;quot; breaks when you find that node already runs a container metrics collector and ships its host metrics through a separate platform, so your exporter would double-count and page on itself.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The failure mode to design against is the opposite: an agent that takes the task at face value, builds exactly what you said, and hands you a clean diff for the wrong thing. A clean diff describes the code, not the world it runs in. Those four silently-failing units were the proof. Every one of them had been shipped after the monitor&amp;#x27;s coverage list was written, and the list was a static allowlist that rots the moment the estate grows. The highest-value item on the whole project turned out to be a watcher-of-the-wiring, an audit that asks whether newly-shipped units are actually covered. I never would have thought to ask for it. The recon found it by refusing to believe the task.&lt;/p&gt;&lt;h2 id=&quot;what-makes-a-finish-line-a-model-can-actually-check&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What makes a finish line a model can actually check?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The eleven files this run produced were never seen by the thing that graded it. A small, fast model scored the finish from the transcript alone, never running a command or opening a file. That one constraint rewrites how you write a done condition. &amp;quot;The eleven files exist and the tests pass&amp;quot; is unverifiable to that grader, so the run either loops forever or passes on the agent&amp;#x27;s word. &amp;quot;One final turn shows fresh command output and a self-audit table&amp;quot; is checkable in a single read.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;An autonomous agent needs a terminal condition, the signal that tells the loop it is finished. The naive version describes the world: files present, tests green, service deployed. The problem is that whatever checks the condition is usually another model, and it has no eyes on your disk. It sees the transcript. A done condition phrased as a fact about the filesystem is therefore either uncheckable, so the run never converges, or taken on faith, so the agent can declare victory while nothing works.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fix is to make the finish line transcript-demonstrable. Require the run to surface, in one final turn, the fresh output of real commands: the file listing, the diff, the passing test log, the grep that proves the wiring is live. Then require a self-audit table that maps each acceptance criterion to the evidence for it. Now the grader has something to grade. This is not ceremony. It is the difference between a run that ends because a model felt finished and a run that ends because it showed its work. Write the condition from the grader&amp;#x27;s real capabilities, not from a template you copied without asking what the grader can see.&lt;/p&gt;&lt;h2 id=&quot;why-gate-the-plan-instead-of-the-finished-code&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why gate the plan instead of the finished code?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Before any implementation, the recon collapsed nineteen candidate work-items into a design with exactly four genuinely-open decisions, and I resolved all four in a single structured prompt. Zero of the nineteen items collided with a project already in flight. The approval gate sat on the design, in the middle of the arc, not on the finished pull request at the end, because the middle is the only place an approval is cheap enough to be honest.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;There is a strong pull toward letting an agent run start to finish and reviewing the pull request at the end. For infrastructure, that is the most expensive possible place to learn the plan was wrong. By the time a diff exists, the agent has committed to an architecture, and your choices are approve-with-misgivings or throw away real work. Reviewing a plan costs minutes. Reviewing and then unwinding a wrong build costs a day.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So the arc has one hard human gate, between research and implementation, and it is the only step that requires me. The agent produces a map: what it will build, what it deliberately will not, every prior decision it reconciled against, and a short list of the forks it genuinely cannot resolve alone. I answer the forks. Everything already settled is asserted plainly so it does not get re-litigated, and only the real decisions reach me. This is also where scope discipline lives. Nineteen items were dispositioned explicitly, each exactly once, so nothing was silently dropped and nothing raced work already running elsewhere. The plan is the artifact you can still change cheaply. Gate there, not at the end.&lt;/p&gt;&lt;h2 id=&quot;how-do-you-make-autonomy-safe-by-construction-rather-than-by-prompt&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you make autonomy safe by construction rather than by prompt?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The remediation layer runs in three tiers, and none of them can take an action, because the capability to act was never written into any of them. The triage model sees only captured text and is never handed a tool it could call. The advice layer emits a proposal into a chat thread and holds no code path that executes. The incident-filing step can open a draft but has no path that closes. Each limit is structural, so a jailbroken or simply confused model hits a wall, not a moment of discretion.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&amp;quot;Propose-only&amp;quot; written in a system prompt is a request, and a request is not a safety property. The version that holds is the one where the capability is absent. The triage step is the clearest case. Instead of an agent with shell access reasoning about my servers, it is a deterministic collector that captures read-only output and a model that receives that text and returns structured JSON. The model cannot run anything, because nothing runnable was ever put in its hands. Its blast radius is the size of a JSON object.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The same shape repeats up the stack. The layer that suggests a fix writes the suggestion to a thread for a human to read; no branch anywhere in it executes the fix, so no prompt can talk it into one. The step that records an incident can draft a writeup, and the rule that an incident closes only after a human has confirmed the root cause and verified the fix is enforced by that step having no close path at all. This is graduated autonomy done honestly: each tier holds exactly the capability it has earned, and the tiers above it are not one clever sentence away. When you catch yourself trusting a model because you told it to behave, you are holding a prompt, not a gate.&lt;/p&gt;&lt;h2 id=&quot;why-isn-t-armed-and-green-the-same-as-working&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why isn&amp;#x27;t &amp;quot;armed and green&amp;quot; the same as working?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Every control that shipped was accepted by firing it, never by watching it sit healthy. We injected a synthetic fault and watched it travel the whole path to a chat thread. We ran one real triage cycle end to end and read the verdict it produced. We handed the refusal paths a case they should reject and confirmed they rejected it. A monitor that is armed and green has told you nothing at all until you have made it catch something.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The most seductive lie in operations is the green dashboard. An alerting rule with no alert, a remediation daemon with a healthy badge, a heartbeat that has never missed: all of them look identical whether they work or are quietly dead. &amp;quot;Armed and green&amp;quot; is a claim about a control&amp;#x27;s own status, not about whether it does its job. I have been burned by a reaper that reported healthy for weeks while reaping nothing, because nothing had ever forced it to act and reveal that it could not.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So the acceptance bar for the controls here was a fired proof. For the coverage audit, ship a unit it should flag and confirm the flag. For the triage path, run a real cycle and read the result. For the propose-only guarantee, feed it a case that must be refused and confirm the refusal returns an error instead of an action. The heartbeat is the honest exception worth naming: rather than trust a new bespoke alert, wire it onto the estate&amp;#x27;s already-proven dead-man monitor, the same one that pages when any other producer goes silent, so it inherits a firing that was tested long ago. A control you have watched catch a real event is a fact. A control you have only watched stay green is a hope wearing the costume of a fact. The extra hour spent writing synthetic faults is the cheapest insurance you will buy on infrastructure, because the alternative is discovering the control was decorative during the incident it existed to catch.&lt;/p&gt;&lt;h2 id=&quot;what-do-the-five-checkpoints-add-up-to&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What do the five checkpoints add up to?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Across the project, five practices did the load-bearing work, and each one guards against a different way agent-built infrastructure fails silently. Not one of them depends on the model being smart. Every one of them is structure the human keeps: an argument before the build, a finish line a grader can read, a gate on the plan, capability bounded by construction, and acceptance by firing the control.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/agent-infra-trust/trust-stack.webp&quot; alt=&quot;The trustworthy-agent-infrastructure harness, drawn as a pipeline with five checkpoints between a task and production. A task enters; recon refutes its false premises; a transcript-demonstrable finish line proves the run actually completed; a human gate approves the plan before any code; autonomy is bounded by construction so no capability exists to misuse; and every control is accepted by firing it. Each checkpoint catches a distinct failure the others cannot see.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Five checkpoints between a task and production. Each catches a failure mode the others are blind to; remove one and that class of silent failure ships.&lt;/figcaption&gt;&lt;/figure&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Checkpoint&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;The silent failure it prevents&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Recon that refutes the task&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;building the wrong thing, cleanly&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Transcript-demonstrable finish line&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;a run that ends on the model&amp;#x27;s own word&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Approval gate on the plan, not the PR&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;learning the architecture was wrong after the work is done&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Autonomy bounded by construction&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;a prompt talked into taking an action&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Acceptance by firing the control&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;a decorative monitor that is green and dead&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The through-line is that trust in agent-built infrastructure is not a feeling you develop about a capable model. It is a property of the harness around it. The model in this arc was the same model that wrote the confident, wrong first draft of the plan. What made its output trustworthy was that every step had a place where a wrong assumption got caught by something other than the model&amp;#x27;s own judgment. Recon caught the wrong premise. The grader caught the unfinished run. I caught the wrong plan. Construction caught the unsafe action. A fired test caught the decorative control.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Point a fast writer at your infrastructure with none of that, and you get exactly what the speed implies: the wrong thing, built well, running green, watching nothing. Keep the five checkpoints, and the agent becomes what it should be, a very fast pair of hands on a short and very well-lit leash. That is not a limit on what agents can do for operations. It is the thing that makes handing them the keys a sane decision instead of a hopeful one.&lt;/p&gt;</content:encoded></item>
<item><title>The Router Is a Trap: Running Other Models Under Claude Code, Off Their Own Subscriptions</title><link>https://www.danmercede.com/guides/off-budget-subagents-under-claude-code</link><guid isPermaLink="true">https://www.danmercede.com/guides/off-budget-subagents-under-claude-code</guid><pubDate>Wed, 08 Jul 2026 07:00:00 +0000</pubDate><description>I wanted Claude Code to keep Opus orchestrating while fanning subagent work out to other models on their own subscriptions. The obvious tool, an ANTHROPIC_BASE_URL router, turned out to be the wrong one: it corrupts the tool calls a subagent depends on, and the real wall is the Terms of Service, not the tooling. Here is what actually works, the three-of-four vendor ban that decides it, and the gated delegation layer I shipped.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I wanted a simple thing. Keep a Claude model orchestrating inside Claude Code, but when it fans out to subagents and background jobs, run that work on other models paid for by other subscriptions, so it stops drawing down my Anthropic allocation. The obvious build is a router: point Claude Code at a proxy, let the proxy translate each call to Gemini or GPT or a local model. That is the popular path, and it is a trap.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Two findings flipped my plan. The router path corrupts tool calls badly enough to be useless for agentic subagents. And the constraint that actually decides the architecture is legal, not technical: reusing a consumer subscription inside a third-party harness is prohibited and actively enforced at three of the four vendors I cared about. What survives both problems is the unglamorous option I already had running.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone trying to cut their Claude Code bill by offloading subagent or workflow fan-out onto other models, and anyone about to install a community &amp;quot;just point it at this proxy&amp;quot; bridge without reading what it does to your sandbox.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;what-are-you-actually-trying-to-buy&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What are you actually trying to buy?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The goal is narrow and worth stating before the tooling: keep the frontier orchestrator (a Claude model) as Claude Code&amp;#x27;s primary driver, and route only the &lt;em&gt;fan-out&lt;/em&gt; (subagents, workflow &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;agent()&lt;/code&gt; calls, background tasks) to other models on their own billing. A Claude Code Max plan meters a weekly token allocation that resets on a fixed cadence, so every subagent you can move off that pool is allocation you keep for the orchestration that has to stay on Claude.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The key word is &lt;em&gt;off-budget&lt;/em&gt;. This is the test every option below has to pass. Running a cheaper Claude model as a subagent does not pass it: Claude Code&amp;#x27;s native per-subagent selection is Anthropic-alias-only, and a Haiku or Sonnet subagent still bills the same Anthropic pool. Saving per-token cost is not the same as taking work off the allocation. Only routing to a genuinely external provider does that, which is why the interesting question is how to reach a non-Anthropic model at all.&lt;/p&gt;&lt;h2 id=&quot;why-does-the-router-path-corrupt-your-tool-calls&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why does the router path corrupt your tool calls?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The most-starred tool in this space, claude-code-router, sits at roughly 35,600 GitHub stars as of mid-2026, and it works by exploiting one documented fact: Claude Code does not validate model IDs behind a custom &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ANTHROPIC_BASE_URL&lt;/code&gt;. Anthropic&amp;#x27;s own model-config docs say the ID check &amp;quot;runs only on the Anthropic API,&amp;quot; so behind a gateway your provider defines the names and Claude Code passes any string through unchecked. That single gap is what every under-the-harness router is built on.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;It works at the transport layer and fails at the semantic one. The dominant, recurring failure is tool-calling corruption. When a non-Claude model runs behind Claude Code&amp;#x27;s Anthropic-shaped API, the translation shim mistranslates tool calls: optional parameters get injected as empty strings (a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Read&lt;/code&gt; arrives with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;pages: &amp;quot;&amp;quot;&lt;/code&gt;), which Claude Code rejects, &amp;quot;producing an infinite loop that burns reasoning tokens until the user manually interrupts.&amp;quot; Server tools like web search get mistranslated into ordinary functions, so search comes back empty or misleading. Some local-model adapters emit plain text that merely &lt;em&gt;mimics&lt;/em&gt; a tool call and then does nothing with it. On top of that, documented proxy layers drop MCP server tools, ignore prompt-cache control, and turn parallel tool use into sequential calls.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For a chat turn, that degradation is survivable. For an agentic subagent whose entire job is to call &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Read&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Edit&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Bash&lt;/code&gt;, and MCP tools reliably, it is disqualifying. The proxy gives you the model behind the endpoint and takes away the tool loop that made the endpoint worth reaching.&lt;/p&gt;&lt;h2 id=&quot;why-is-the-terms-of-service-the-real-constraint-not-the-tooling&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why is the Terms of Service the real constraint, not the tooling?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here is the finding that actually decided the architecture: as of mid-2026, reusing a consumer &lt;em&gt;subscription&lt;/em&gt; inside a third-party harness is prohibited and enforced at three of the four vendors, so the tooling question is downstream of a legal one. The enforcement is not theoretical, and in one case it is not even loud.&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Anthropic&lt;/strong&gt; made the prohibition explicit in a February 2026 consumer-terms revision (tokens permitted only in Claude Code and Claude.ai), and on April 4, 2026 blocked third-party harnesses from using Claude Max subscription rate limits outright. Bring-your-own API keys still work; subscription-OAuth reuse does not.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Google&lt;/strong&gt; is the cautionary tale. It deployed abuse detection and &lt;em&gt;silently&lt;/em&gt; banned paying AI Ultra subscribers (a $249.99-per-month plan) who drove third-party CLIs on their consumer OAuth in mid-April 2026, with reports of the ban cascading to Gmail and Workspace. It then stopped serving consumer and Pro or Ultra tiers to its IDE code-assist extensions on June 18, 2026. If your estate leans on Google Workspace, an OAuth-reuse ban is not a coding-tool outage, it is a Gmail outage.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;xAI&amp;#x27;s&lt;/strong&gt; terms bar automated, non-human (bot or script) access to Grok and reserve suspension at sole discretion; the only sanctioned programmatic path is the separate pay-per-token API.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;OpenAI is the outlier.&lt;/strong&gt; It publicly sanctions reusing a ChatGPT subscription in third-party tools with &amp;quot;the same usage as Codex,&amp;quot; and Codex-into-third-party has an official adapter. Subscription Codex is governed by the consumer ChatGPT terms and metered as plan usage, not raw API billing.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The net for the original dream of &amp;quot;route all my subscriptions through one proxy&amp;quot;: it is durable for exactly one of them, GPT via Codex. Every other subscription seam rides reverse-engineered OAuth that the vendor is actively closing. One corroborating datum: a Claude-Max-as-API seam that made the rounds earlier in 2026 survived about two months before the vendor shut it. Treat any subscription-brokering feature as a liability with a short shelf life, not a capability. For Gemini or Grok specifically, use a paid API key, never the consumer login.&lt;/p&gt;&lt;h2 id=&quot;what-actually-works-shell-out-to-headless-clis&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What actually works: shell out to headless CLIs&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The mechanism that clears both the tool-fidelity trap and the Terms-of-Service wall is the one I already ran in production for months: keep Claude orchestrating and shell out to each other vendor&amp;#x27;s &lt;em&gt;headless CLI&lt;/em&gt; as a subagent. Instead of translating Claude Code&amp;#x27;s API to another provider, you invoke &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;codex exec&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;gemini&lt;/code&gt;, or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;grok -p&lt;/code&gt; as a subprocess, each authenticated to its own login, each doing its own native tool calling.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This inverts every problem the proxy created. There is no Anthropic-to-OpenAI translation shim, so there is no empty-string-parameter loop and no mistranslated web search; each CLI speaks its own protocol natively. The work runs on that CLI&amp;#x27;s own credentials, so a Codex call bills the ChatGPT plan and a local-model call bills nothing, both off the Anthropic allocation. And it matches a doctrine most mature harnesses already follow, which is to prefer a working CLI over a bespoke integration: a post-push review pipeline that drives Codex headless is the same pattern, just pointed at review instead of fan-out.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The tradeoff is honest. You give up the illusion of one unified &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/v1&lt;/code&gt; endpoint and instead orchestrate N processes, joined by your own wait-and-collect logic. In exchange you get native tool loops, per-vendor billing, and no dependency on a subscription seam that gets patched out from under you.&lt;/p&gt;&lt;h2 id=&quot;how-do-you-keep-the-delegation-layer-from-bypassing-your-sandbox&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you keep the delegation layer from bypassing your sandbox?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The security catch is real and specific: nearly every community CLI-bridge I looked at launches its child CLI with a permission-or-sandbox bypass flag baked in (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--dangerously-skip-permissions&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--dangerously-bypass-approvals-and-sandbox&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--yolo&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;-y&lt;/code&gt;). That is convenient and it is also how you hand an untrusted subagent&amp;#x27;s output the keys to your workstation. When I shipped my own delegation layer in July 2026, the rails were the point, not an afterthought, and they are enforced in code with tests, not just documented.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Five rails carry the safety, and each is one small function:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Never emit a bypass flag.&lt;/strong&gt; A denylist is asserted in a unit test so a bypass flag can never regress into the spawned &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;argv&lt;/code&gt;. The read-only sandbox is the default; write access is an explicit, separate opt-in.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Scrub the child environment by whitelist, not denylist.&lt;/strong&gt; The child gets &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;PATH&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;HOME&lt;/code&gt;, and its &lt;em&gt;own&lt;/em&gt; provider key, and nothing else. A future secret-shaped variable you have not thought of yet does not survive by default, because the default is to drop, not to keep.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Resolve the CLI by absolute path.&lt;/strong&gt; Never exec a bare command name; resolve it to an absolute path first so a planted binary earlier on &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;PATH&lt;/code&gt; cannot shadow the real one.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Close stdin.&lt;/strong&gt; Recent Codex versions deadlock on a non-TTY pipe, so the child gets &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;stdin&lt;/code&gt; pointed at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/dev/null&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-python&quot;&gt;# read-only by default; workspace-write is an explicit, separate opt-in
sandbox = &amp;quot;workspace-write&amp;quot; if write else &amp;quot;read-only&amp;quot;
argv = [resolve_cli(&amp;quot;codex&amp;quot;), &amp;quot;exec&amp;quot;, &amp;quot;-s&amp;quot;, sandbox, task]
assert not any(flag in argv for flag in BYPASS_FLAGS)  # tested, not just asserted here&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fifth rail I only found by running the thing, which is the reminder that live verification beats reasoning. Grok&amp;#x27;s CLI needs an explicit headless flag: &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;grok -p &amp;quot;&amp;lt;prompt&amp;gt;&amp;quot;&lt;/code&gt; prints the answer and exits, but a &lt;em&gt;positional&lt;/em&gt; prompt with no flag opens the interactive TUI and hangs a non-interactive caller. A positional prompt cost me a two-minute timeout before I read the help text. The regression is now pinned by a test that asserts the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;-p&lt;/code&gt; flag is present in the built &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;argv&lt;/code&gt;.&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-python&quot;&gt;# a positional prompt opens grok&amp;#x27;s interactive TUI and hangs a non-TTY caller;
# -p / --single prints and exits.
argv = [resolve_cli(&amp;quot;grok&amp;quot;), &amp;quot;-p&amp;quot;, task, &amp;quot;--output-format&amp;quot;, &amp;quot;json&amp;quot;]&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;what-should-you-actually-build&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What should you actually build?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If you take one architecture from this, take this shape, which I settled on after two research runs and a working implementation in July 2026: Claude orchestrates, and it delegates to a small, gated set of headless CLIs chosen by Terms-of-Service durability first and cost second.&lt;/p&gt;&lt;ol class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-decimal&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Orchestrator:&lt;/strong&gt; a Claude model in Claude Code, unchanged.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Primary off-budget subagent:&lt;/strong&gt; GPT via &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;codex exec&lt;/code&gt; on the ChatGPT plan. This is the one durable subscription-reuse path, so it earns the default slot.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Cheap bulk fan-out:&lt;/strong&gt; a metered API key (DeepSeek and Kimi run roughly an order of magnitude cheaper than frontier Claude on input tokens) or a free local model on your own GPU box via a local inference endpoint. No subscription to reuse means no ban risk.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Gemini or Grok, if at all:&lt;/strong&gt; only on a paid API key, isolated from the primary Google account. Never the consumer login.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Mechanism:&lt;/strong&gt; headless-CLI subprocess, not a proxy. If you ever want a proxy for &lt;em&gt;API-key&lt;/em&gt; models, the routers are fine; just treat their subscription-brokering as the liability it is.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The three paths compared, so the choice is legible:&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Feature&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Router proxy&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Headless CLI (recommended)&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Native per-subagent&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Keeps Claude orchestrating&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Only if you patch aliases&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes, cleanly&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Routes subagents to other providers&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes, but Claude-unaware&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes, per call, model named&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;No, Anthropic aliases only&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Off the Anthropic budget&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Yes&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;No, still bills the same pool&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Tool-calling reliability&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Poor and brittle&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Native per CLI&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Native&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Subscription legality&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Rides the prohibited seam&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Depends on vendor (GPT sanctioned)&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Not applicable&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The plan I walked in with, a single elegant router, was the wrong instinct dressed up as the clean one. The winning design is a handful of subprocesses behind a security gate, choosing each model by whether its vendor will still let you in next month. Boring, durable, and off-budget. That is the trade, and it is a good one.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;em&gt;The vendor terms and enforcement dates in this guide are time-sensitive and were accurate to mid-2026; enforcement is active and moving, so re-verify a vendor&amp;#x27;s current terms before you build on any subscription seam.&lt;/em&gt;&lt;/p&gt;</content:encoded></item>
<item><title>Why Most AI Governance Starts Too Late</title><link>https://www.danmercede.com/thoughts/2026-07-07-why-ai-governance-starts-too-late</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-07-07-why-ai-governance-starts-too-late</guid><pubDate>Tue, 07 Jul 2026 07:00:00 +0000</pubDate><description>Observability makes AI systems legible; only pre-execution authority evaluation makes them governable.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Policies, dashboards, and audit logs make AI systems observable. They do not make them governable. Once an agent can change state, governance has to stop the action before the mutation - not explain it afterward.&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Most AI governance starts after the action.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A model selects a tool. An agent executes a workflow. A database is updated. A message is sent. A file crosses a permission boundary. A business process advances.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Then the system logs it. Then a monitor evaluates it. Then a dashboard reports it. Then a human reviewer decides whether the action should have happened.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is not governance. It is after-action accounting.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The distinction matters because the systems now being deployed into enterprises are no longer passive prediction services. They are execution systems. They call tools, move data, update records, trigger workflows, produce decisions, and operate across persistent context.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;When an AI system can mutate state, the decisive question is not whether the organization can explain what happened afterward. The decisive question is whether the system could have been stopped &lt;em&gt;before&lt;/em&gt; the state changed.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Most AI governance architectures answer that question too late. This article argues for moving the control point - from after-action review to pre-execution authority - and lays out what that shift requires in practice: where enforcement has to sit, what artifact it has to produce, and how to test whether a given architecture actually governs or merely observes.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/thoughts/2026-07-07-why-ai-governance-starts-too-late/hero-authority-gate.svg&quot; alt=&quot;Hero diagram: runtime governance and the pre-execution authority gate. Post-hoc governance evaluates after execution, when unauthorized actions have already occurred; the pre-execution control plane evaluates authority before the mutation. Telemetry is not enforcement.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;/figure&gt;&lt;h2 id=&quot;the-category-error&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The category error&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Many organizations still treat AI governance as an organizational layer. They create policies, define responsible-AI principles, assign risk owners, build dashboards, add logging, monitor outputs, run evaluations, and create escalation paths.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;These controls are useful. They are not sufficient.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The category error is treating governance as a &lt;em&gt;visibility&lt;/em&gt; problem when the actual failure mode is an &lt;em&gt;execution-control&lt;/em&gt; problem. Visibility answers a retrospective question - &lt;em&gt;what happened?&lt;/em&gt; Governance has to answer a different one - &lt;em&gt;what is allowed to proceed?&lt;/em&gt; Those are not the same system.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A log records an event. A dashboard visualizes an event. An alert reacts to an event. A policy document describes intended behavior. An evaluation estimates behavior under test conditions. None of those mechanisms, by themselves, prevents a disallowed action from completing. They observe, classify, and inform. They do not govern.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Governance begins when execution depends on authority.&lt;/p&gt;&lt;h2 id=&quot;state-mutation-changes-the-problem&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;State mutation changes the problem&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The traditional model-risk frame assumed a relatively contained system. A model generated a score. A human interpreted the score. A downstream system acted separately. Validation could occur periodically, and governance could sit &lt;em&gt;around&lt;/em&gt; the model.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That assumption breaks when AI systems become agents. An agent does not merely produce an output. It forms an intent, chooses a tool, passes parameters, invokes an API, retrieves context, writes data, and continues operating. The risk is no longer whether the model&amp;#x27;s answer was accurate. The risk is whether the system can perform an unauthorized action.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A &lt;em&gt;state mutation&lt;/em&gt; is any durable or externally consequential change made by the system - a database update, a payment instruction, a CRM change, a permission change, a document transfer, a message sent to a customer, a workflow approval. Once the mutation completes, governance is downstream. At that point the system is no longer deciding whether the action should occur; it is deciding how to explain, remediate, or document the action that already occurred. That is a fundamentally different control problem.&lt;/p&gt;&lt;h2 id=&quot;the-problem-with-post-hoc-governance&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The problem with post-hoc governance&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Post-hoc governance accepts a dangerous premise: that unauthorized action may occur before intervention. This premise is often hidden inside otherwise reasonable architecture - the system executes, the log records, the monitor detects, the alert fires, the reviewer investigates, the organization remediates.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That sequence can support incident response. It cannot serve as the primary enforcement model for high-risk AI execution, because the latency &lt;em&gt;is&lt;/em&gt; the failure:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If an AI workflow sends regulated data to the wrong endpoint, the alert does not undo disclosure.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If an agent modifies a production record, the log does not prevent the mutation.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If a system approves an action outside policy, the dashboard does not restore authority.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If a tool invocation uses authorized credentials with unauthorized parameters, post-hoc review does not change the fact that the system was allowed to act.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Governance evaluated after execution is telemetry. Telemetry is not enforcement.&lt;/p&gt;&lt;h2 id=&quot;the-missing-unit-is-the-execution-boundary&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The missing unit is the execution boundary&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The central unit of AI governance is not the model, the prompt, the policy document, or the dashboard. It is the &lt;strong class=&quot;font-semibold text-white&quot;&gt;execution boundary&lt;/strong&gt; - the point where intent becomes action.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Before that boundary, the system can reason, propose, retrieve, rank, summarize, or plan. After that boundary, the system changes something. Governance has to sit &lt;em&gt;at&lt;/em&gt; that boundary - not near it, not parallel to it, not downstream from it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;At the execution boundary, the system must answer a small set of deterministic questions before the action proceeds:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;What is the system trying to do?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Who or what authorized it?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Which policy applies?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Is the requested action inside scope?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Are the parameters authorized?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Is the current context still valid?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;What evidence proves the decision?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;What happens if the answer is ambiguous?&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If the system cannot answer those questions before mutation, it is not governing execution - it is letting execution run ahead of governance. That is the structural failure.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/thoughts/2026-07-07-why-ai-governance-starts-too-late/control-flow.svg&quot; alt=&quot;Diagram: where governance sits relative to the state change. The execution boundary is the point where intent becomes action; post-hoc governance sees telemetry after it, pre-execution governance halts before it.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;/figure&gt;&lt;h2 id=&quot;authority-must-be-evaluated-before-mutation&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Authority must be evaluated before mutation&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Authority is not the same thing as capability. A system may be &lt;em&gt;capable&lt;/em&gt; of calling an API; that does not mean it is &lt;em&gt;authorized&lt;/em&gt; to call it. A system may be capable of sending an email; that does not mean it is authorized to send &lt;em&gt;this&lt;/em&gt; email, to &lt;em&gt;this&lt;/em&gt; recipient, with &lt;em&gt;this&lt;/em&gt; content. A system may be capable of updating a record; that does not mean it is authorized to update &lt;em&gt;this&lt;/em&gt; field, in &lt;em&gt;this&lt;/em&gt; account, under &lt;em&gt;this&lt;/em&gt; workflow, at &lt;em&gt;this&lt;/em&gt; time.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Most AI systems collapse capability and authority. They grant tools to the agent and rely on prompts, policies, or application logic to keep behavior inside bounds. That is weak architecture. The model should not be the enforcement layer for its own behavior.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A governed system separates intent generation from execution authority. The AI system can &lt;em&gt;propose&lt;/em&gt; an action. A control plane decides whether the action can &lt;em&gt;proceed&lt;/em&gt;:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-python&quot;&gt;intent = agent.propose_action(context)

decision = authority_gate.evaluate(
    intent=intent,
    policy=active_policy,
    actor=session_identity,
    context=current_context,
)

receipt = receipt_ledger.write(
    intent=intent,
    decision=decision,
    policy_version=active_policy.version,
    actor=session_identity,
)

if decision != &amp;quot;permit&amp;quot;:
    halt_execution(receipt)
else:
    execute_action(intent, receipt)&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The important detail is not the syntax. It is the sequence. The action does not execute first. The log does not arrive later. The reviewer does not become the first real control point. Authority is evaluated before mutation, a receipt is created as part of the execution path, and ambiguity halts the transaction. Execution depends on governance.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/thoughts/2026-07-07-why-ai-governance-starts-too-late/merge-gate.svg&quot; alt=&quot;Diagram: fail-closed merge admission. The reviewer verdict is an input to be checked by a deterministic parser and authority gate, not an authority to be obeyed; ambiguity halts.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;/figure&gt;&lt;h2 id=&quot;fail-open-is-the-default-failure-mode&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Fail-open is the default failure mode&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Many systems fail open without saying so. A policy service times out, so the workflow proceeds. A classifier returns an uncertain score, so the action routes anyway. A guardrail fails to parse the response, so the application retries. A logging service is unavailable, so the transaction completes without evidence. A human approval step is skipped because the system treats it as asynchronous.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;These are not operational edge cases. They are governance failures.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A governed system must &lt;strong class=&quot;font-semibold text-white&quot;&gt;fail closed&lt;/strong&gt;. Fail-closed means execution halts when authority cannot be verified, when policy cannot be resolved, when context is incomplete, when the requested parameters exceed scope, or when the evidence artifact cannot be written.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is uncomfortable for product teams, because fail-closed systems interrupt workflows. That interruption is the point. Governance that cannot stop execution is advisory. It may be useful, informative, even necessary for audit review - but it is not enforcement.&lt;/p&gt;&lt;h2 id=&quot;logs-are-not-receipts&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Logs are not receipts&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Audit logs are often treated as proof of governance. They are not. Logs are &lt;em&gt;observational&lt;/em&gt; artifacts: they record that something happened. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Receipts&lt;/strong&gt; are &lt;em&gt;enforcement&lt;/em&gt; artifacts: they prove that a specific action was evaluated under a specific policy, by a specific authority, at a specific time, before execution proceeded.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The distinction is mechanical. A log can be written after the fact; a receipt must be generated in the transaction path. A log can describe an event; a receipt binds the authority decision to the mutation. A log supports investigation; a receipt supports non-repudiation.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A log says: &lt;em&gt;this happened.&lt;/em&gt; A receipt says: &lt;em&gt;this was authorized, under this policy, by this authority, before it happened.&lt;/em&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For AI systems that mutate state, this distinction determines whether the organization holds audit-grade evidence or merely operational telemetry. If a mutation can complete without a receipt, it can complete without governance.&lt;/p&gt;&lt;h2 id=&quot;evals-are-not-control-planes&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Evals are not control planes&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Evaluations are useful. They measure model behavior under defined conditions, and they help teams compare systems, detect regressions, test prompts, and assess risk before deployment. Frameworks like NIST&amp;#x27;s AI Risk Management Framework formalize this organizational discipline, and reporting conventions such as model cards make a system&amp;#x27;s intended use and limitations legible [1][2].&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;But an eval is not a runtime control plane. An eval does not authorize a specific action, inspect every tool invocation, bind policy to a transaction, halt execution when authority is missing, or prove that a state mutation was permitted before it occurred. The same is true for red-team reports, policy reviews, benchmark scores, and model cards. They contribute to governance; they do not replace enforcement.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A system can pass evaluations and still execute an unauthorized action in production. This happens because production execution contains variables that test environments cannot fully capture: live context, changing permissions, tool parameters, user-specific state, workflow timing, data dependencies, and long-running agent behavior. The governance layer must operate where those variables exist. That means runtime.&lt;/p&gt;&lt;h2 id=&quot;guardrails-are-not-enough-when-capability-remains&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Guardrails are not enough when capability remains&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Guardrails often sit at the model or application layer. They filter prompts, classify outputs, block certain content, detect policy violations, and shape behavior. These mechanisms are useful for many classes of risk. They are not sufficient for state-mutating workflows when the underlying capability remains available.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If an agent has network access, credentials, tool access, and a route to production systems, the organization is relying on the agent &lt;em&gt;not to misuse&lt;/em&gt; capabilities it already possesses. That is restriction - the capability exists but is blocked by a rule.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Capability removal&lt;/strong&gt; is different. It means the execution environment does not possess the primitive required to perform the unauthorized action: no direct network route, no persistent credential, no raw socket access, no production database path, no tool invocation outside a mediated boundary. A system cannot misuse a capability it does not have.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is why substrate design matters. The execution environment should be treated as untrusted compute. It should not be able to route itself around governance. The agent can produce intent; it should not independently possess the authority, credentials, and network pathways required to turn that intent into production state.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/thoughts/2026-07-07-why-ai-governance-starts-too-late/enforcement-spectrum.svg&quot; alt=&quot;Diagram: the enforcement-location spectrum. Most AI controls are advisory or observational, sitting before deployment or outside the execution path; only a pre-execution gate sits in the path and can halt a mutation.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;/figure&gt;&lt;h2 id=&quot;drift-is-a-governance-problem-across-time&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Drift is a governance problem across time&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;AI systems do not only fail at a single transaction. They fail over time. A workflow that behaved correctly on Monday can become unsafe by Friday because context changed, permissions changed, memory accumulated, prompts shifted, tools changed, or the model began operating near guardrail boundaries.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Per-action authority checks are necessary, but not sufficient alone. Governance must also constrain behavior &lt;em&gt;across time&lt;/em&gt; - not as passive monitoring, but as enforceable containment. Increasing guardrail triggers, repeated near-denials, escalation loops, changes in tool-use frequency, semantic drift in task execution, and the width of authority windows all matter.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A system that had authority yesterday does not automatically have authority tomorrow. Authority should decay. Execution paths should not remain open indefinitely. Long-running AI systems require temporal constraints because risk compounds across sequences, not only individual actions. Monitoring watches that sequence; governance intervenes in it.&lt;/p&gt;&lt;h2 id=&quot;the-three-question-audit&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The three-question audit&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Any AI governance architecture can be tested with three questions.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;1. Where does enforcement occur?&lt;/strong&gt; If enforcement occurs after execution, it is telemetry. If it occurs outside the execution path, it is advisory. If it occurs inside the transaction path before mutation, it can govern. The location of enforcement determines whether the system can stop the action.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;2. What happens on failure?&lt;/strong&gt; If policy cannot be resolved, does execution proceed or halt? If the authority service is unavailable, does the workflow continue? If the parameters are ambiguous, does the system route anyway? If the receipt ledger cannot commit, does the mutation still complete? The failure path reveals the real governance model: a system that proceeds under ambiguity is fail-open; a system that halts under ambiguity is fail-closed.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;3. What artifact proves enforcement?&lt;/strong&gt; A dashboard is not proof. A policy document is not proof. A model evaluation is not proof. An application log is not enough. The artifact must show what action was requested, what policy was evaluated, who or what authorized it, what decision was made, when it occurred, and whether execution was permitted or denied. Without that artifact, enforcement is inferred - and inferred enforcement is not audit defensibility.&lt;/p&gt;&lt;h2 id=&quot;what-better-ai-governance-looks-like&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What better AI governance looks like&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A governed AI execution system has a different shape. The model does not directly mutate production state. The agent does not hold standing authority. The execution environment does not contain unnecessary capability. Tool calls are mediated through an enforcement boundary. Every state-mutating action is represented as a structured intent. Policy is evaluated deterministically before execution. Ambiguity defaults to denial. Material actions generate receipts. Behavior is constrained across time. Substrate access is limited by physical and infrastructural boundaries, not only model instructions.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This architecture does not eliminate AI risk. It changes &lt;em&gt;where&lt;/em&gt; risk is handled. Instead of discovering unauthorized behavior after mutation, the system constrains execution before mutation. Instead of relying on logs to reconstruct events, it produces receipts as part of the execution path. Instead of trusting the model to obey policy, it places policy outside the model and inside the control plane. Instead of allowing capability and hoping it is not misused, it removes unnecessary capability from the execution environment.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is the shift from advisory governance to runtime governance.&lt;/p&gt;&lt;h2 id=&quot;the-practical-boundary&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The practical boundary&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Not every AI interaction requires the same level of enforcement. A summarization assistant reading public documentation does not need the same controls as an agent that can approve refunds, modify customer records, change permissions, execute trades, or move regulated data.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The boundary is state mutation. When AI produces text that a human can ignore, governance can be lighter. When AI changes external state, governance must be structural. The higher the blast radius, the less acceptable post-hoc governance becomes. For low-risk workflows, observation may be enough. For consequential workflows, execution must depend on authority. That is the practical line.&lt;/p&gt;&lt;h2 id=&quot;governance-has-to-move-earlier&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Governance has to move earlier&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Most AI governance starts too late because it was built around the wrong operating model - one that assumes AI systems are things to be evaluated, monitored, and reviewed. That was adequate when AI systems produced recommendations. It is inadequate when AI systems execute.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Execution changes the control surface. Once an AI system can mutate state, governance has to move from policy documentation to execution architecture, from dashboards to transaction boundaries, from logs to receipts, from after-action review to pre-execution authority.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The question is not whether the organization &lt;em&gt;has&lt;/em&gt; AI governance. The question is whether governance can stop the system before the state changes. If the answer is no, governance starts too late. And if governance starts after mutation, it is not governance. It is hope.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;em&gt;Dan Mercede is a systems architect focused on governed automation and runtime enforcement for enterprise AI. He writes about fail-closed system design, control planes, and the architecture of human-owned intelligence.&lt;/em&gt;&lt;/p&gt;&lt;h2 id=&quot;references&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;References&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[1] M. Mitchell, S. Wu, A. Zaldivar, P. Barnes, L. Vasserman, B. Hutchinson, E. Spitzer, I. D. Raji and T. Gebru, &lt;a href=&quot;https://arxiv.org/abs/1810.03993&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-copper-400 underline decoration-copper-500/40 underline-offset-2 hover:text-copper-300&quot;&gt;Model Cards for Model Reporting&lt;/a&gt; (2019), Proceedings of the Conference on Fairness, Accountability, and Transparency (FAT* &amp;#x27;19)&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[2] National Institute of Standards and Technology, &lt;a href=&quot;https://www.nist.gov/itl/ai-risk-management-framework&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-copper-400 underline decoration-copper-500/40 underline-offset-2 hover:text-copper-300&quot;&gt;Artificial Intelligence Risk Management Framework (AI RMF 1.0)&lt;/a&gt; (2023), NIST AI 100-1&lt;/p&gt;</content:encoded></item>
<item><title>Giving Your Coding Agent Web Access Without Wrecking Its Context Window</title><link>https://www.danmercede.com/guides/giving-your-agent-web-access</link><guid isPermaLink="true">https://www.danmercede.com/guides/giving-your-agent-web-access</guid><pubDate>Sun, 05 Jul 2026 07:00:00 +0000</pubDate><description>A coding agent that reads the raw web spends most of its tokens on HTML boilerplate, trips provider rate limits, and floods its own context. I measured the waste (one page was 9,541 tokens raw and 1,678 as clean markdown, an 82% cut) and built the fix: a few thin CLIs and a subagent-offload pattern that keep raw pages out of the main context. Free and self-hosted first.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Most coding agents get web access the lazy way: a fetch tool that returns raw HTML, a search tool wired straight into the main context, and a metered API behind both. It works in a demo. Then the agent reads three documentation pages, and half its context window is &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;&amp;lt;div class=&amp;quot;nav&amp;quot;&amp;gt;&lt;/code&gt; and inline analytics.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I spent a day hardening the web-access layer for my own agents and measured every option on two axes almost nobody ranks first: &lt;strong class=&quot;font-semibold text-white&quot;&gt;cost&lt;/strong&gt; and &lt;strong class=&quot;font-semibold text-white&quot;&gt;rate-limit resilience&lt;/strong&gt;. The result is a stack that is free or self-hosted for the load-bearing paths, plus one structural pattern that matters more than any single tool: keep raw pages out of the main context by design.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide is the version I wish I had at the start. Every number is from a public source or a run you can repeat.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone giving an autonomous agent (Claude Code, a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;-p&lt;/code&gt; headless pipeline, Codex, an in-house harness) the ability to search, read, and drive the web, and paying for the tokens.&lt;/p&gt;&lt;h2 id=&quot;why-does-giving-an-agent-web-access-wreck-its-context-window&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why does giving an agent web access wreck its context window?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Because raw HTML is mostly not content. In one measured fetch, the same page was &lt;strong class=&quot;font-semibold text-white&quot;&gt;9,541 tokens as raw HTML and 1,678 tokens as clean markdown, an 82% reduction&lt;/strong&gt;. Typical HTML-to-markdown conversion lands nearer 20 to 30%, but the direction is constant: you are paying to put boilerplate into the model&amp;#x27;s attention, then paying again on every following turn.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The waste compounds three ways. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Context flood:&lt;/strong&gt; a large docs page is roughly 25,000 tokens of input, and it stays resident for the rest of the conversation. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Cost:&lt;/strong&gt; search results and fetched content are billed as input tokens on the turn they arrive and every turn after, so a page you read once is re-billed until it ages out. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Rate limits:&lt;/strong&gt; point a fan-out of parallel agents at a metered search API and you hit &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;429&lt;/code&gt; almost immediately. The lazy path fails on all three at once.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Context rot is the quiet one. Even on a large context window, recall degrades as the window fills. A page of HTML you never needed is not free just because it fit.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/web-access/efficient-web-access.webp&quot; alt=&quot;The cost of lazy web access versus a hardened stack. On the left, an agent&amp;#x27;s context window overloaded with raw HTML noise (ads and trackers, navigation menus, script tags, CSS, boilerplate) wasting about 82 percent of tokens, plus context rot and parallel-search rate limits. On the right, a three-part hardened stack: self-hosted search that aggregates engines without per-query fees, extract-first fetching that converts a URL to clean markdown before it reaches the model, and a summarizing subagent that passes back only distilled facts.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Raw HTML can burn 80 percent of an agent&amp;#x27;s context. Self-hosted search, extract-first fetching, and a summarizing subagent keep only the signal.&lt;/figcaption&gt;&lt;/figure&gt;&lt;h2 id=&quot;what-are-the-three-axes-of-agent-web-access&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What are the three axes of agent web access?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Web access is not one capability, it is three, and they want different tools. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Search&lt;/strong&gt; (find the URLs), &lt;strong class=&quot;font-semibold text-white&quot;&gt;fetch&lt;/strong&gt; (read a page without drowning in it), and &lt;strong class=&quot;font-semibold text-white&quot;&gt;browser automation&lt;/strong&gt; (click, fill, scroll a live app). In a 2026-07 review of the field, the cheapest resilient answer differed per axis: self-hosted for search and fetch, local-and-free for the browser.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Conflating them is how people end up overpaying. You do not need a headless Chromium to read a blog post; an extract-first markdown fetch handles the large majority of retrieval. You do not need a metered search API to read a page whose URL you already have. Name the axis, then pick the cheapest tool that covers it. Reserve the expensive, stateful browser for genuine interaction: clicks, forms, infinite scroll, auth walls.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The rest of this guide takes the axes in order, cheapest and most rate-limit-resilient first.&lt;/p&gt;&lt;h2 id=&quot;what-is-the-cheapest-most-rate-limit-resilient-search-stack&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What is the cheapest, most rate-limit-resilient search stack?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Self-hosting wins outright. A self-hosted &lt;strong class=&quot;font-semibold text-white&quot;&gt;SearXNG&lt;/strong&gt; metasearch instance runs on a 5 to 10 dollar per month VPS, aggregates roughly 95 to 200 engines (Google, Bing, Brave, DuckDuckGo and more) behind a JSON API, and charges nothing per query. Put a Valkey or Redis cache in front of it, because individual upstream engines rate-limit a single client IP hard, and the cache is what absorbs that.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If you want a hosted API instead, rank by the free tier and the rate cap, not the marketing. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Exa&lt;/strong&gt; gives 20,000 free requests a month. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Brave&lt;/strong&gt; gives about 1,000 free queries a month but caps the free tier at &lt;strong class=&quot;font-semibold text-white&quot;&gt;1 query per second&lt;/strong&gt;, which breaks the instant you do parallel fan-out search, a common agent pattern. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Perplexity Sonar&lt;/strong&gt; and &lt;strong class=&quot;font-semibold text-white&quot;&gt;Kagi&lt;/strong&gt; are metered with no meaningful free tier. Anthropic&amp;#x27;s native &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;web_search&lt;/code&gt; is the priciest per call at &lt;strong class=&quot;font-semibold text-white&quot;&gt;10 dollars per 1,000 searches&lt;/strong&gt; plus token costs, though its companion &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;web_fetch&lt;/code&gt; adds no per-fetch fee and ships a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;max_content_tokens&lt;/code&gt; cap worth using.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;One honest caveat carried from the research: a &amp;quot;Serper plus a reader&amp;quot; stack was reported at about 11 dollars a month for 10,000 searches and 10,000 extractions, against roughly 96 dollars for a single-vendor API on the same workload. That specific figure rests on a single blog and should be read as directional. The ranking (self-hosted free, then Exa/Brave free tiers, then metered) is the corroborated part.&lt;/p&gt;&lt;h2 id=&quot;what-is-the-cheapest-way-to-read-a-page-without-flooding-context&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What is the cheapest way to read a page without flooding context?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Extract-first, markdown, in that order. The single biggest lever is converting a page to clean markdown before it reaches the model, which cut tokens 82% in the measured case above. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Jina Reader&lt;/strong&gt; is the cheap default: prefix any URL with the reader endpoint and you get markdown back, free at 20 requests per minute with no key, or 10 million tokens on a free key, and it is open-source so you can self-host it. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Trafilatura&lt;/strong&gt; is a self-hosted Python extractor with no API cost and strong extraction quality, though it will not render JavaScript.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For scraping proper, the same free-first ranking holds. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Crawl4AI&lt;/strong&gt; is Apache-2.0 with no per-page fees; your only cost is the box it runs on. Managed &lt;strong class=&quot;font-semibold text-white&quot;&gt;Firecrawl&lt;/strong&gt; trades that for convenience at roughly 83 dollars a month on its standard tier. The crossover is volume: at low and mid volume the managed credits are cheaper than running infrastructure; past high volume, the self-hosted crawler wins. Pick the tool, but do the markdown conversion no matter which you pick. Handing a model raw HTML is the mistake; the extractor is incidental.&lt;/p&gt;&lt;h2 id=&quot;why-does-a-thin-cli-beat-an-mcp-server-for-agent-web-tools&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why does a thin CLI beat an MCP server for agent web tools?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Because an MCP server costs context on every host, and a CLI costs almost none. As of 2026-07, Claude Code defers MCP tool schemas until a tool is invoked, so an MCP is cheapish there. But Codex has no such deferral: every MCP schema loads in full, every session, and a harness that already carries a dozen servers pays that tax constantly. A thin CLI you shell out to via Bash costs zero resident context until the moment you call it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So for anything I want available on more than one agent host, I ship a CLI, not an MCP. I built three this way: a search CLI over a grounded-answer API, a live-social search CLI, and a page-reader over Jina. Each self-wraps its own secret retrieval (read the key from the environment, or transparently re-run under a secrets manager if it is not set) so the caller passes no credentials, and each takes a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--json&lt;/code&gt; flag so an agent can parse it. One implementation, every host, near-zero context. The rule generalizes: prefer a working CLI over its MCP unless the tool genuinely needs a stateful session.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Browser automation is the one real exception. Playwright, driving a real browser, is stateful and does not reduce cleanly to a stateless CLI, so it stays an MCP or an in-process library. There, the free-and-local option still wins: Playwright is Apache-2.0, runs on your own machine at zero LLM cost, and hits about 98% reliability on deterministic scripts. Running Playwright&amp;#x27;s MCP in its CLI mode reportedly cuts token use about 4x versus the protocol mode. Metered cloud browsers exist (Browserbase, Stagehand&amp;#x27;s hosted tier) but the free tiers are thin (Browserbase caps the free tier at 1 browser-hour, 3 concurrent sessions, 15-minute sessions), so reserve them for scale you have actually hit.&lt;/p&gt;&lt;h2 id=&quot;how-do-you-build-the-fetch-tool-and-why-run-it-in-a-subagent&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you build the fetch tool, and why run it in a subagent?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Because the tool only wins if the raw page never reaches the main agent at all. The pattern is small: a fetch CLI that returns bounded markdown, invoked inside a subagent whose job is to read the page and return only the distilled slice the main agent asked for. The main context sees a paragraph, not 25,000 tokens. Even the markdown never lands in the parent conversation.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fetch CLI itself is about 120 lines. The load-bearing parts:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-python&quot;&gt;import math, os, urllib.request  # the full CLI also imports json (for --json) and sys

READER = &amp;quot;https://r.jina.ai/&amp;quot;
# The default python-urllib User-Agent gets a 403 from the reader&amp;#x27;s bot filter.
# Send a real browser UA. This one line is the difference between 200 and 403.
UA = os.environ.get(&amp;quot;WEB_READ_UA&amp;quot;,
    &amp;quot;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 &amp;quot;
    &amp;quot;(KHTML, like Gecko) Chrome/126 Safari/537.36&amp;quot;)

def fetch(url, key=None):
    req = urllib.request.Request(READER + url, method=&amp;quot;GET&amp;quot;)
    req.add_header(&amp;quot;User-Agent&amp;quot;, UA)
    req.add_header(&amp;quot;X-Return-Format&amp;quot;, &amp;quot;markdown&amp;quot;)
    if key:                                  # optional, raises free-tier limits
        req.add_header(&amp;quot;Authorization&amp;quot;, f&amp;quot;Bearer {key}&amp;quot;)
    with urllib.request.urlopen(req, timeout=60) as r:
        return r.read().decode(&amp;quot;utf-8&amp;quot;, &amp;quot;replace&amp;quot;)

def budget(md, max_tokens):
    # A stdlib token estimate: ~4 chars per token. Label it estimated.
    est = math.ceil(len(md) / 4)
    if not max_tokens or est &amp;lt;= max_tokens:
        return md, est, False
    return md[: max_tokens * 4].rstrip() + &amp;quot;\n\n... [truncated]&amp;quot;, max_tokens, True&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Three details earn their place. It is &lt;strong class=&quot;font-semibold text-white&quot;&gt;keyless by default&lt;/strong&gt; (the reader&amp;#x27;s free tier needs no key), so the tool works with zero setup and only reaches for a key to raise limits. It carries a &lt;strong class=&quot;font-semibold text-white&quot;&gt;token budget&lt;/strong&gt; (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--max-tokens&lt;/code&gt;), so a runaway page cannot blow the context even inside the subagent. And it emits &lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;--json&lt;/code&gt;&lt;/strong&gt; so the calling agent gets structured output, not a wall of text. The browser User-Agent is the one non-obvious line: a live smoke test, not the unit tests, is what caught the reader returning &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;403&lt;/code&gt; to the default &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;python-urllib&lt;/code&gt; agent. Unit tests pass with a mocked network; only exercising the real endpoint surfaces that class of bug.&lt;/p&gt;&lt;h2 id=&quot;what-should-you-actually-adopt&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What should you actually adopt?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Start free and self-hosted, add metered tools only where you have measured a need. As of 2026-07 the stack I run is: self-hosted SearXNG behind a cache for search, a keyless Jina-backed reader CLI for fetch (run in a subagent so raw pages never hit the main context), a self-hosted crawler for bulk scraping, and local Playwright for the genuine browser work. Native provider search sits behind all of it as a metered last resort.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The through-line is not a specific tool, it is two rules. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Convert to markdown before the model sees a page&lt;/strong&gt;, which reclaims most of the token bill on day one. And &lt;strong class=&quot;font-semibold text-white&quot;&gt;offload the fetch to a subagent&lt;/strong&gt;, so the raw content, even cleaned, is summarized down before it touches the conversation the main agent has to keep coherent. Everything else is a cost-and-rate-limit optimization on top of those two moves.&lt;/p&gt;&lt;h2 id=&quot;the-takeaway&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The takeaway&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Giving an agent web access is easy; giving it web access that does not quietly bankrupt its context window takes two deliberate choices. Extract to markdown, and read in a subagent. Do those, rank every tool by free-tier and rate cap before you reach for a paid API, and prefer a thin CLI over an MCP for anything that is not a stateful browser. The lazy path costs you a large slice of your web-read tokens, up to 80% on a boilerplate-heavy page, and a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;429&lt;/code&gt; under load. The fix is a day of plumbing and a 120-line script, and it pays for itself the first time your agent reads the docs.&lt;/p&gt;</content:encoded></item>
<item><title>The Reviewer Blessed the Bug: Why Agent-Written Code Needs Layered Review</title><link>https://www.danmercede.com/guides/why-agent-code-needs-layered-review</link><guid isPermaLink="true">https://www.danmercede.com/guides/why-agent-code-needs-layered-review</guid><pubDate>Sat, 04 Jul 2026 07:00:00 +0000</pubDate><description>I put one 20-line log-scrubbing function through four independent review layers while hardening a self-hosted LLM-observability backup. Each layer caught a real defect the others missed, and one layer reproduced a secret leak that an earlier layer had explicitly certified as safe. The case for layered review of AI-written code, with the five bugs and the fixes.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A security reviewer read my diff and signed off on a specific claim: the credential-scrubbing was correct, &amp;quot;encoded access/secret forms are distinct, no overlap mis-redaction.&amp;quot; It was thorough. It traced every log path. It was also wrong.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;One review layer later, a different reviewer took the same function, fed it two overlapping credentials, and printed the result: a &lt;strong class=&quot;font-semibold text-white&quot;&gt;partial secret sitting in the log line&lt;/strong&gt;. The redaction had eaten part of one secret and left the tail of another exposed. Same code, same afternoon, opposite verdict.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That gap between &amp;quot;a careful reviewer certified this&amp;quot; and &amp;quot;an independent adversary broke it in one run&amp;quot; is the entire case for layered review. When an agent writes your code, and increasingly it does, no single review pass is a safety net. The passes have to be &lt;strong class=&quot;font-semibold text-white&quot;&gt;independent&lt;/strong&gt;, because independence is the only thing that makes their blind spots not line up.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide walks the real arc: one small log-scrubber, four review layers, five defects, and the exact modality that let each layer catch what the others could not see. Every bug here is a shape you can copy into your own checks.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone shipping code an AI agent wrote, or building the review harness that gates it, especially around self-hosted infrastructure where a quiet failure hides for weeks.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;what-was-actually-being-hardened&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What was actually being hardened?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;In a 2026-07 pass on a self-hosted LLM-observability stack (Langfuse on top of ClickHouse, with backups pushed to a MinIO object store and an encrypted off-box replica via restic), I hardened one function: a log scrubber that strips credentials out of backup-failure messages before they reach a cron log. Roughly 20 lines. It is exactly the kind of small, security-adjacent code an agent produces in seconds and a human skims in seconds.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The scrubber matters because backup scripts fail loudly, and a failure message often contains the command that failed, which contains the credentials. If a nightly backup dies and dumps an S3 secret key into a log that ships to your aggregator, you have turned a reliability event into a credential-exposure event. The scrubber&amp;#x27;s whole job is to make that impossible.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So it is a good test case: tiny, high-stakes, and the sort of thing everyone assumes is too small to need real review. It went through four layers anyway. Here is what each one saw.&lt;/p&gt;&lt;h2 id=&quot;why-does-live-environment-recon-catch-what-no-diff-can&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why does live-environment recon catch what no diff can?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Before any diff review, I ran the backup once against the real host, and that single run exposed a defect no code reader could have found: the script selected its MinIO container with a fuzzy name match, and the host happened to run &lt;strong class=&quot;font-semibold text-white&quot;&gt;two&lt;/strong&gt; MinIO containers. The match grabbed the wrong one, hit Access Denied on a bucket it could not see, and reported a false CRITICAL every night while the actual backup was fine.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;No diff review catches this. The code was locally correct. &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;docker ps&lt;/code&gt; filtered by a name substring returns the first match, and on a one-MinIO dev box that first match is always the intended one. The bug only exists in the shape of the live environment, where a second, unrelated MinIO happened to sort ahead of the one the backup wanted. The lesson is old and keeps being true: &lt;strong class=&quot;font-semibold text-white&quot;&gt;a passing local test and a clean diff describe the code, not the world it runs in.&lt;/strong&gt; The fix was to select the container by exact name, defaulted in-script, so a fuzzy match can never wander onto the wrong instance.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Recon is a review layer in its own right. It senses runtime state, a modality a static reviewer cannot reach. If your agent-review harness is all diff-readers, it stays blind to an entire class of defect that only appears on contact with production.&lt;/p&gt;&lt;h2 id=&quot;what-does-a-pre-pr-reviewer-fleet-catch-that-a-single-reviewer-misses&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What does a pre-PR reviewer fleet catch that a single reviewer misses?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Before the pull request opened, I ran three independent reviewers over the diff (adversarial, correctness, and security), and the security pass caught a redaction bug hiding in plain sight: the scrubber replaced each secret with a shell pattern substitution, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;s=&amp;quot;${s//${SECRET}/&amp;lt;redacted&amp;gt;}&amp;quot;&lt;/code&gt;, and the unquoted needle is treated as a &lt;strong class=&quot;font-semibold text-white&quot;&gt;glob&lt;/strong&gt;. A secret containing a bracket expression like &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;[ab]&lt;/code&gt; is read as a character class, fails to match itself literally, and passes through the log unredacted. A &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;*&lt;/code&gt; or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;?&lt;/code&gt; in the secret fails the other way, over-matching and redacting adjacent log text. Both are wrong; only one leaks, and you cannot predict which without knowing the secret.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fix is one edit, quoting the needle so the match is always literal:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# unquoted: a bracket-expression secret leaks, and * or ? over-redacts
s=&amp;quot;${s//${SECRET}/&amp;lt;redacted&amp;gt;}&amp;quot;
# quoted: the secret is matched literally, both failure modes closed
s=&amp;quot;${s//&amp;quot;${SECRET}&amp;quot;/&amp;lt;redacted&amp;gt;}&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The value of the fleet is not three times the reading. It is three &lt;strong class=&quot;font-semibold text-white&quot;&gt;different&lt;/strong&gt; readings. Point one reviewer at a diff and you get one model&amp;#x27;s priors about what is worth checking. Point three with distinct adversarial framings and their blind spots do not fully overlap, so the union of what they notice is strictly larger. This is the cheapest layer to add and the one people skip most, because a single &amp;quot;looks good&amp;quot; from a capable model feels like enough. It is not enough. It is one sample.&lt;/p&gt;&lt;h2 id=&quot;why-did-the-post-push-layer-catch-what-the-security-reviewer-had-certified&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why did the post-push layer catch what the security reviewer had certified?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;With an access key of &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABC&lt;/code&gt; and a password of &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABCDEF&lt;/code&gt;, a post-push reviewer ran the already-certified scrubber on the line &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;password=ABCDEF&lt;/code&gt; and got back &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;password=&amp;lt;redacted&amp;gt;DEF&lt;/code&gt;: a partial secret in the log. The pre-PR security pass had checked this exact function for overlapping-secret bugs and signed off, &amp;quot;encoded access/secret forms are distinct, no overlap mis-redaction.&amp;quot; A second model, running the code instead of reasoning about it, broke that certification in one run.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here is the whole bug. The scrubber redacted each secret in sequence. If a shorter secret is a substring of a longer one, the shorter replacement fires first, mutates the line, and the longer secret no longer matches itself, so its tail survives:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# ACCESS=&amp;#x27;ABC&amp;#x27;  RESTIC=&amp;#x27;ABCDEF&amp;#x27;
s=&amp;quot;${s//&amp;quot;${ACCESS}&amp;quot;/&amp;lt;redacted&amp;gt;}&amp;quot;   # &amp;#x27;password=ABCDEF&amp;#x27; -&amp;gt; &amp;#x27;password=&amp;lt;redacted&amp;gt;DEF&amp;#x27;
s=&amp;quot;${s//&amp;quot;${RESTIC}&amp;quot;/&amp;lt;redacted&amp;gt;}&amp;quot;   # no longer matches; &amp;#x27;DEF&amp;#x27; leaks&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fix is to stop treating the needles as an ordered list and redact them &lt;strong class=&quot;font-semibold text-white&quot;&gt;longest-first&lt;/strong&gt;, so no shorter needle can fragment a longer secret before that secret is matched whole:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# sort the needles by length, descending, then redact each literally
if [ ${#needles[@]} -gt 0 ]; then
  readarray -t needles &amp;lt; &amp;lt;(printf &amp;#x27;%s\n&amp;#x27; &amp;quot;${needles[@]}&amp;quot; | awk &amp;#x27;{print length&amp;quot;\t&amp;quot;$0}&amp;#x27; | sort -rn | cut -f2-)
  for n in &amp;quot;${needles[@]}&amp;quot;; do s=&amp;quot;${s//&amp;quot;${n}&amp;quot;/&amp;lt;redacted&amp;gt;}&amp;quot;; done
fi&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Longest-first closes the substring case, where one secret sits wholly inside another. It does not close the rarer case of two distinct secrets that overlap in the log line, a suffix of one equal to a prefix of another; random credentials almost never do this, but if yours can, redact in a single combined pass instead of a loop. State the bound; do not sell the fix as total.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is the load-bearing section, so sit with what happened. A competent reviewer did not skip the overlap question. It &lt;strong class=&quot;font-semibold text-white&quot;&gt;considered the overlap question and answered it wrong&lt;/strong&gt;, because it reasoned about the encoded forms being distinct and missed that the raw forms could overlap. A second, independent adversary, running the function instead of reasoning about it, found the counterexample immediately. Certification by one reviewer is an argument. Reproduction by an independent one is a fact. When you can afford only one, prefer the layer that runs the code.&lt;/p&gt;&lt;h2 id=&quot;can-a-merge-gate-itself-be-a-review-layer&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Can a merge gate itself be a review layer?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The last defect was not in the code at all, and the CI gate surfaced it: my new test used credential-shaped fixtures (values with an &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;AKIA&lt;/code&gt; prefix, the AWS access-key-ID shape), and a secret scanner flagged them and &lt;strong class=&quot;font-semibold text-white&quot;&gt;transitively blocked the merge&lt;/strong&gt;, even though that scanner was not one of the branch&amp;#x27;s required status checks. A second scanner, run on the identical fixtures, passed. The gate disagreed with itself.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The trap is one layer of indirection. The scanner was not in the branch-protection required list, so it looked advisory. But a separate fail-closed meta-check that &lt;em&gt;was&lt;/em&gt; required carried its own internal list of checks it waits on, and the scanner was in it. So a &amp;quot;non-required&amp;quot; check gated the merge through a required one. Reading only the branch-protection settings would tell you the merge was clear. It was not.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Two portable lessons fall out. First, &lt;strong class=&quot;font-semibold text-white&quot;&gt;never hardcode credential-shaped fixtures&lt;/strong&gt;; use obviously-fake, low-entropy placeholders that still exercise the code path, because a scanner cannot tell your test data from a live leak and should not have to. Second, when a merge is blocked and the required checks look green, &lt;strong class=&quot;font-semibold text-white&quot;&gt;read what your meta-gates actually wait on&lt;/strong&gt;, not just the protection rules. The scanner also scans full branch history, so neutralizing the fixtures at the tip does not clear a hit an earlier commit introduced; a squash-merge that collapses to the net diff does.&lt;/p&gt;&lt;h2 id=&quot;why-don-t-the-layers-just-catch-the-same-things&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why don&amp;#x27;t the layers just catch the same things?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Across the arc, four layers caught five distinct defects with no overlap in the catches (the merge gate alone surfaced two, the credential-shaped fixtures and the transitive block), because each layer senses a different modality. Live recon senses runtime state. The pre-PR fleet senses the diff with fresh adversarial priors. The post-push pass senses by an independent model that runs the code. The CI gate senses policy and scanner state. Independent modalities have independent blind spots, which is the only reason stacking them pays off.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/layered-review/independent-review-stack.webp&quot; alt=&quot;The independent review stack: four orthogonal review layers for agent-written code, shown as a pipeline. Live environment recon senses runtime state; a pre-PR multi-model fleet senses adversarial logic; post-push functional execution senses by running the code; and the CI merge gate senses policy and scanner state. Each layer catches a defect the others miss.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Four review layers, each sensing a different modality. Stacking identical reviewers shares blind spots; independent ones do not.&lt;/figcaption&gt;&lt;/figure&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Review layer&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Sensing modality&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;The defect it caught&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Live recon&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;runtime state on the real host&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;wrong container selected, false CRITICAL nightly&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Pre-PR reviewer fleet&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;fresh adversarial read of the diff&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;glob-unquoted redaction needle&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Post-push pass&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;an independent model that runs the code&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;sequential-substitution partial-secret leak&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;CI merge gate&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;policy and scanner state&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;credential-shaped fixtures, transitive merge block&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Stacking identical reviewers does not work. Three passes from the same model with the same framing tend to miss the same things, because they share priors. The gain comes from &lt;strong class=&quot;font-semibold text-white&quot;&gt;orthogonality&lt;/strong&gt;: a runtime probe sees what a reader cannot, a code-runner sees what a reasoner cannot, a scanner sees what a designer forgot. The arc is a clean natural experiment for this, because one layer certified the precise property another layer broke. If the two had shared a modality, they would have shared the blind spot, and the leak would have shipped.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The uncomfortable corollary for AI-written code: the model that wrote the code, the model that reviews the diff, and the model that reasons about correctness are correlated failure sources when they are the same model reasoning the same way. Independence is not a nice-to-have on top of review. Independence &lt;strong class=&quot;font-semibold text-white&quot;&gt;is&lt;/strong&gt; the review.&lt;/p&gt;&lt;h2 id=&quot;how-do-you-build-this-without-drowning-in-process&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you build this without drowning in process?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You do not need a 20-layer gauntlet on every one-line change; you need a few genuinely independent layers on anything security-adjacent, and a smaller set elsewhere. In practice, for agent-written infrastructure code, four layers cover the modalities that matter, and each is cheap once wired.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Concretely:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Run it against the real environment before you trust the diff.&lt;/strong&gt; One live execution surfaces the wrong-container, wrong-endpoint, and &amp;quot;works-on-the-dev-box&amp;quot; class that no reader catches. Recon is a layer.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Fan out a small pre-PR reviewer set with distinct framings,&lt;/strong&gt; not one reviewer three times. Adversarial, correctness, and a security pass on the named bug classes. The union beats the average.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Add one post-push pass by a different model that runs the code,&lt;/strong&gt; not just reads it. This is the layer that catches what your best reviewer confidently blessed. Weight reproduction over certification.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Treat the CI gate as a reviewer and read its real dependency graph,&lt;/strong&gt; including what your fail-closed meta-checks wait on. And keep credential-shaped data out of tests entirely.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The through-line is not &amp;quot;more review.&amp;quot; It is &lt;strong class=&quot;font-semibold text-white&quot;&gt;independent&lt;/strong&gt; review. Five defects in one 20-line function, each invisible to every layer but one, is the normal shape of software when a fast writer meets a single slow reader. That is not a verdict on the agent; it is arithmetic. Give the writer four orthogonal readers instead, and the blind spots stop lining up.&lt;/p&gt;</content:encoded></item>
<item><title>Building a Governed, Double-Send-Safe Delivery Pipeline for Agent Outputs</title><link>https://www.danmercede.com/guides/governed-double-send-safe-delivery</link><guid isPermaLink="true">https://www.danmercede.com/guides/governed-double-send-safe-delivery</guid><pubDate>Fri, 03 Jul 2026 07:00:00 +0000</pubDate><description>I built a multi-agent system that emailed real people on a business&#39;s behalf, then retired the business. The delivery pipeline is the part worth keeping: the design that guarantees an agent never sends a customer the same email twice and never ships anything a human did not approve. Here is the pattern, the failure mode that shaped it, and the reaper rule most teams get wrong.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I built a multi-agent system to run a small business. Agents drafted work, and some of that work left the building: emails to real people, exported documents, delivered artifacts. I later retired the business on market grounds, but the delivery pipeline is the piece I would rebuild without changing a line. It answers a question most agent demos skip: how do you let an autonomous system send things on a person&amp;#x27;s behalf without ever double-sending, misfiring, or shipping something nobody approved?&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide is the pattern, generalized and stripped of the business it ran. It is for anyone whose agent sends consequential output: customer email, outbound messages, exported records, anything where &amp;quot;sent twice&amp;quot; or &amp;quot;sent without sign-off&amp;quot; is a real cost rather than a cosmetic bug.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The one-line version:&lt;/strong&gt; treat every outbound action as a durable, leased, fail-closed transaction with a human approval gate and an evidence receipt, and make the crash-recovery path refuse to re-send by default. The rest of this guide is that sentence, unpacked.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;why-does-an-agent-that-sends-email-send-it-twice&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;Why does an agent that sends email send it twice?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The double-send is not a rare edge case; it is the default behavior of a naive queue the first time a worker dies mid-send. A worker claims a send task, hands the message to the mail server, and then is killed (out of memory, a deploy, a reboot) before it writes &amp;quot;done.&amp;quot; The task still looks unfinished, so the next worker picks it up and sends again. The customer gets two emails and you get a support ticket.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Every safe-delivery decision below exists to close that window. The failure is structural, not bad luck: the moment of maximum danger is the gap between &amp;quot;the side effect happened in the outside world&amp;quot; and &amp;quot;the system recorded that it happened.&amp;quot; A crash in that gap is when a retry becomes a duplicate. You cannot make the gap zero, so you design for a crash inside it.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;what-does-the-governed-delivery-pipeline-look-like&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;What does the governed delivery pipeline look like?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The pipeline composes into one spine, and the rule is that no consequential send skips the approval, single-send, and attest gates. The score and review stages decide which items a human reads first rather than gating every send. Six stages, each durable before the next begins:&lt;/p&gt;&lt;ol class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-decimal&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Produce.&lt;/strong&gt; The agent generates the artifact in full (for email, a complete RFC 5322 message) and stages it. Nothing is sent inline at generation time.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Persist.&lt;/strong&gt; Write the intent (the staged artifact plus a queue row and a receipt) to durable storage before anything leaves the process. If you crash now, the intent survives and is inspectable.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Score.&lt;/strong&gt; Attach a confidence score to the output. This is the input to review routing, not a gate by itself.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Review.&lt;/strong&gt; Route low-confidence items to a human review queue. High-confidence items still do not bypass the next stage.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Approve.&lt;/strong&gt; A fail-closed approval gate. The release is blocked by default and proceeds only on an explicit, recorded authorization.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Send and attest.&lt;/strong&gt; Send exactly once under a lease, then reconcile and write an evidence receipt describing what happened.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The important property is that steps 2 through 6 are separate durable transitions, not one function call. Each stage can crash and resume without losing or duplicating work, because the state that matters lives in the store, not in a worker&amp;#x27;s memory.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/governed-double-send-safe-delivery/governed-delivery-spine.webp&quot; alt=&quot;The governed delivery spine: six durable stages (produce, persist, score, review, approve, and send with attest), with low-confidence items branching to a human review queue and each arrow a separate durable transition.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Six durable stages; each arrow is its own transaction, so a crash between stages never loses or duplicates work.&lt;/figcaption&gt;&lt;/figure&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;how-do-you-make-the-send-idempotent-when-a-worker-dies&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you make the send idempotent when a worker dies?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is the stage most teams get wrong, and the fix is a rule, not a library. Use row-level leasing so exactly one worker owns a task at a time, and make external-send actions non-requeueable on lease expiry. In Postgres the leasing primitive is a claim under &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;: concurrent workers select different rows, never the same one, so two workers cannot both send.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The lease is what makes crash recovery safe, and the reaper is where the real decision lives. A reaper reclaims tasks whose lease expired because the worker died. For ordinary internal work, reclaiming and re-running is correct. For an external send it is a loaded gun: if the worker died after the mail server accepted the message but before it recorded success, re-running sends a duplicate. So the rule is:&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;An external-send action is non-requeueable on lease expiry. When its lease dies, the reaper strands the task for human reconciliation instead of re-running it.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That is the whole trick. A visible stuck task beats an invisible double-send. You trade an automatic recovery you cannot prove is safe for a manual one a human can resolve in seconds by checking whether the message actually went out. Internal, side-effect-free tasks stay auto-requeueable; only the actions that touch the outside world are stranded. Encode the action type in the task and let the reaper branch on it; do not rely on a human remembering the distinction.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/governed-double-send-safe-delivery/double-send-safe-reaper.webp&quot; alt=&quot;The double-send-safe reaper: a worker claims a task under a lease and dies mid-send; when the lease expires the reaper branches on action type, requeuing internal tasks but stranding external sends for human reconciliation to avoid a duplicate customer send.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;The recovery path branches on action type: internal tasks requeue, external sends strand for a human.&lt;/figcaption&gt;&lt;/figure&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;how-do-you-keep-the-pipeline-fail-closed&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;How do you keep the pipeline fail-closed?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Fail-closed means the safe state is the default and every unsafe action requires an explicit, positive signal to proceed. Three defaults carry most of the weight:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Sending is off until switched on.&lt;/strong&gt; A single delivery-enabled flag gates all outbound sends, defaulting to off. A misconfiguration, a fresh environment, or a half-deployed release sends nothing rather than sending wrong.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The sender identity is checked, not assumed.&lt;/strong&gt; At the point of send, the from-address is checked against a verified sending domain, and a transport guard refuses to send if encrypted transport is expected but the connection would fall back to an unencrypted or wrong port. These checks live in send code, not in a config comment.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Nothing consequential ships without a recorded approval, and everything that ships leaves a receipt.&lt;/strong&gt; The approval gate is the choke point; the receipt is the audit trail. A stranded, unreconciled send is deliberately left without a success receipt, because its outcome is genuinely unknown until a human checks.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A short operational note, because it cost me. A swallowed import error once left a preflight check silently broken for about 65 days; a reboot finally surfaced it, and the whole time the failure had been read as a benign &amp;quot;dependency unavailable&amp;quot; state. A suppressed error that reads as a normal state is its own failure mode. Fail-closed only helps if the closed state is loud.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;when-do-you-actually-need-this-and-when-is-it-overkill&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;When do you actually need this, and when is it overkill?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Match the machinery to the blast radius of a wrong send. For low-stakes internal output (a draft in a channel, a log line, a suggestion a human will obviously see and fix), this full pipeline is overkill and you should not build it. The cost of the ceremony exceeds the cost of the mistake.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You need it the moment a wrong or duplicate send has real cost: customer-facing email, anything touching money, anything with a compliance or legal record, anything a recipient acts on. For those, the pipeline is the floor, not the gold-plating. The tell is simple: if &amp;quot;sent twice&amp;quot; or &amp;quot;sent without sign-off&amp;quot; would generate a support ticket, a refund, or a liability, you are in scope.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The pattern outlived the business I built it for, which is the honest reason it is here rather than in a product. The decision to retire that system, and the principle that a clean architecture is a reason to document the pattern rather than keep funding the business, is its own argument, made in the companion essay &lt;a href=&quot;https://www.danmercede.com/thoughts/2026-07-03-architecture-fit-is-not-business-justification&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-copper-400 underline decoration-copper-500/40 underline-offset-2 hover:text-copper-300&quot;&gt;Architecture Fit Is Not Business Justification&lt;/a&gt;. Build the pipeline when the sends are consequential. Skip it when they are not. And whatever you build, make the reaper refuse to re-send by default.&lt;/p&gt;</content:encoded></item>
<item><title>Architecture Fit Is Not Business Justification</title><link>https://www.danmercede.com/thoughts/2026-07-03-architecture-fit-is-not-business-justification</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-07-03-architecture-fit-is-not-business-justification</guid><pubDate>Fri, 03 Jul 2026 07:00:00 +0000</pubDate><description>A clean architecture is a reason to document the pattern, never a reason to keep funding the business it was built to run.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I built a fail-closed, governed multi-agent system to run a first business, and then I retired it. Not because it broke. Because I re-tested the two businesses it was built for against the 2026 market and neither cleared the bar. The architecture being clean was a reason to document the pattern, never a reason to keep the business breathing. Architecture fit is not business justification.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The system was worth building, which is exactly why the temptation to keep it was strong. Nothing consequential shipped without passing a fail-closed policy gate and, where it mattered, a human. Every outbound artifact ran one spine: produce it, persist the intent before anything left, score the confidence and route the low-confidence work to a person, gate the release closed by default, send it once under a lease, then reconcile and write a receipt. The detail I am proudest of is the reaper. When a worker dies after handing an email to the mail server but before recording success, a naive reaper re-queues the task and the customer gets it twice. Mine treats an external send as non-requeueable on lease expiry: it strands the task for a human to reconcile rather than risk a duplicate. A visible stuck task beats an invisible double-send.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;None of that saves a business the market has closed. One lane, an agent-run paid-media practice, was being absorbed by the ad platforms themselves; the best-funded pure-play in that space had already pivoted to a human shop and then shut down. The other, a governed compliance-adjacent service, had real demand and real budget, but sat between billion-dollar incumbents bundling the feature for free and headcount shops owning the done-for-you tier. Clean governance changed neither verdict. So I re-tested the thesis before spending more, and when it failed, I stopped.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Killing your own working system on evidence is a competence, not a failure. The salvage is the pattern, never the product. The produce, review, approve, send, attest spine and the double-send-safe reaper outlive the business they were built for, and they map onto every agent that now acts on a person&amp;#x27;s behalf. A control is only worth what it does to the live target. A business is only worth keeping when the market it serves clears its own test. When it does not, document the architecture and walk.&lt;/p&gt;</content:encoded></item>
<item><title>The Alibi of a Green Gate</title><link>https://www.danmercede.com/thoughts/2026-06-29-the-alibi-of-a-green-gate</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-29-the-alibi-of-a-green-gate</guid><pubDate>Mon, 29 Jun 2026 07:00:00 +0000</pubDate><description>A control is what it does to the live target, not what it is declared to be; armed, configured, and exit-0 is an alibi for action, not proof of it.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A control is what it does to the live target, not what it is declared to be. We build the gate, the reaper, the required check, the backup. We arm it, it runs, it exits zero, the dashboard is green. Then the leak it was meant to kill is still leaking, the bad merge it was meant to refuse already landed, the stale image it was meant to replace is still serving months-old code. Present, configured, armed, inert. None of those four facts is the one that matters.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The failure has one shape, and it is structural. The control&amp;#x27;s selection model, its liveness model, or its notion of &amp;quot;done&amp;quot; does not match the world it governs, so it sweeps an empty set, or whitelists by naming the bad states it knows and silently passing the ones it does not, or inspects the merged PR instead of the running artifact. Nothing throws. A no-op and a success produce the identical green. The green proves the control ran; it says nothing about whether it bit.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That is the whole trap: &amp;quot;armed and error-free&amp;quot; reads exactly like &amp;quot;remediating,&amp;quot; and the reading is free while the proof costs a probe. So pay for the probe. The question is never &amp;quot;did it run.&amp;quot; It is &amp;quot;did anything in the live world change because it fired&amp;quot;: did the leak shrink, did the merge get refused, did the served bytes turn over. A control that cannot point at an effect on its target has not earned the word &amp;quot;working.&amp;quot; Trust the effect, never the exit code. A green gate over an inert control is theater. Prove it bit.&lt;/p&gt;</content:encoded></item>
<item><title>Why 88% of Real LLM Schemas Get Rejected (and the `additionalProperties` Trap Behind It)</title><link>https://www.danmercede.com/guides/why-llm-schemas-get-rejected</link><guid isPermaLink="true">https://www.danmercede.com/guides/why-llm-schemas-get-rejected</guid><pubDate>Sun, 28 Jun 2026 07:00:00 +0000</pubDate><description>I ran a JSON-Schema linter across 50 real LLM tool and structured-output schemas from the OpenAI/Anthropic cookbooks, popular agent frameworks, and official MCP servers. 88% would be rejected by at least one provider. Here&#39;s the one field behind most of it, and how to catch it in CI instead of production.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I shipped a small tool that lints JSON Schemas against each LLM provider&amp;#x27;s structured-output constraints, then did the obvious thing: I pointed it at &lt;strong class=&quot;font-semibold text-white&quot;&gt;50 real, public schemas&lt;/strong&gt;, the kind developers actually feed to these APIs, to check whether the problem I&amp;#x27;d built for was real or imagined.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;It was real. &lt;strong class=&quot;font-semibold text-white&quot;&gt;44 of the 50 (88%) would be rejected by at least one major provider. Only 3 passed all five.&lt;/strong&gt; And the single biggest cause is one field almost nobody sets by hand.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide walks through what breaks, why it&amp;#x27;s invisible until production, the one-line shape of the fix, and how to make it a CI check instead of a runtime surprise. Every number here comes from a reproducible run you can repeat on your own schemas (link at the end).&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone shipping structured outputs, tool calls, or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;response_format&lt;/code&gt; to more than one LLM provider, directly or through a framework like Instructor, LangChain, or an MCP server.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;1-the-experiment&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;1. The experiment&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The corpus was 50 schemas, each traceable to a public source:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;15&lt;/strong&gt; from the OpenAI and Anthropic cookbooks and SDKs.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;17&lt;/strong&gt; from popular agent frameworks, Instructor, LangChain, Pydantic-AI, CrewAI, Guidance, LlamaIndex.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;18&lt;/strong&gt; tool &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;inputSchema&lt;/code&gt;s from official Model Context Protocol servers.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Each was linted against OpenAI, Anthropic, Gemini, Mistral, and Cohere. The result:&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Provider&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Would reject&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;OpenAI&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;88%&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Strict Structured Outputs, firm, documented&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Mistral&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;88%&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Conservative rule (see the honesty note)&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Cohere&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;26%&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Needs ≥1 required field per object&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Anthropic&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;12%&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Rejects certain validation keywords&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Gemini&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;0% errors&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;But 26% warn (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;$ref&lt;/code&gt; recursion, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;anyOf&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The portability story is the real headline: &lt;strong class=&quot;font-semibold text-white&quot;&gt;only 3 of 50 schemas were clean across all five providers.&lt;/strong&gt; A schema isn&amp;#x27;t &amp;quot;valid&amp;quot; or &amp;quot;invalid&amp;quot;, it&amp;#x27;s valid &lt;em&gt;for a specific provider&lt;/em&gt;, and the surfaces don&amp;#x27;t agree.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;2-the-dominant-failure-openai-strict-structured-outputs&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;2. The dominant failure: OpenAI strict Structured Outputs&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;OpenAI&amp;#x27;s strict mode is the firmest, best-documented constraint surface, and the one most schemas violate. It enforces two rules that ordinary JSON Schema does not:&lt;/p&gt;&lt;ol class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-decimal&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Every object must set &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Every property must be listed in &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;required&lt;/code&gt;.&lt;/strong&gt; (To make a field &amp;quot;optional,&amp;quot; you make it nullable, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;[&amp;quot;string&amp;quot;, &amp;quot;null&amp;quot;]&lt;/code&gt;, and keep it required.)&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Plus a few smaller ones, including: &lt;strong class=&quot;font-semibold text-white&quot;&gt;no &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;default&lt;/code&gt;&lt;/strong&gt;, and a restricted keyword set.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here&amp;#x27;s a real MCP tool schema, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;get_channel_history&lt;/code&gt;, about as simple as schemas get:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
  &amp;quot;properties&amp;quot;: {
    &amp;quot;channel_id&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;, &amp;quot;description&amp;quot;: &amp;quot;The ID of the channel&amp;quot; },
    &amp;quot;limit&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;number&amp;quot;, &amp;quot;description&amp;quot;: &amp;quot;Number of messages&amp;quot;, &amp;quot;default&amp;quot;: 10 }
  },
  &amp;quot;required&amp;quot;: [&amp;quot;channel_id&amp;quot;]
}&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Two properties. It passes Anthropic, Gemini, and Cohere. Against OpenAI strict mode it fails &lt;strong class=&quot;font-semibold text-white&quot;&gt;three&lt;/strong&gt; ways at once:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;no &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt; on the object,&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;limit&lt;/code&gt; is optional (not in &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;required&lt;/code&gt;),&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;limit&lt;/code&gt; carries a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;default&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This isn&amp;#x27;t a contrived edge case, it&amp;#x27;s the &lt;em&gt;default shape&lt;/em&gt; of a hand-written tool schema, and the default output of most Pydantic models (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties&lt;/code&gt; simply isn&amp;#x27;t emitted, and optional fields stay out of &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;required&lt;/code&gt;). Across the 50-schema corpus, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties&lt;/code&gt; alone accounted for &lt;strong class=&quot;font-semibold text-white&quot;&gt;134 flags&lt;/strong&gt;, more than every other keyword combined.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;3-why-it-s-invisible-until-production&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;3. Why it&amp;#x27;s invisible until production&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The constraints are documented, so why does everyone hit them? Because the failure mode is opaque. When a provider rejects a schema, the API returns the equivalent of &lt;em&gt;&amp;quot;invalid schema&amp;quot;&lt;/em&gt;, a 400, without telling you &lt;strong class=&quot;font-semibold text-white&quot;&gt;which&lt;/strong&gt; constraint, at &lt;strong class=&quot;font-semibold text-white&quot;&gt;which&lt;/strong&gt; path, failed. So the loop becomes: ship → 400 in production (or in a flaky eval) → bisect the schema by hand → discover it was a missing &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties&lt;/code&gt; three levels deep in a nested object.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Multiply that by five providers, each with a different surface, and &amp;quot;just hand-port the schema&amp;quot; becomes a recurring tax instead of a one-time fix.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;4-the-shape-of-the-fix&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;4. The shape of the fix&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For the Slack example, OpenAI-strict-valid looks like:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
  &amp;quot;additionalProperties&amp;quot;: false,
  &amp;quot;properties&amp;quot;: {
    &amp;quot;channel_id&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;string&amp;quot;, &amp;quot;description&amp;quot;: &amp;quot;The ID of the channel&amp;quot; },
    &amp;quot;limit&amp;quot;: { &amp;quot;type&amp;quot;: [&amp;quot;number&amp;quot;, &amp;quot;null&amp;quot;], &amp;quot;description&amp;quot;: &amp;quot;Number of messages&amp;quot; }
  },
  &amp;quot;required&amp;quot;: [&amp;quot;channel_id&amp;quot;, &amp;quot;limit&amp;quot;]
}&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt; added, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;limit&lt;/code&gt; made nullable and moved into &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;required&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;default&lt;/code&gt; dropped. The semantics are preserved (a caller can still pass &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;null&lt;/code&gt; for &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;limit&lt;/code&gt;), but now it satisfies strict mode.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The mechanical part is easy &lt;em&gt;once you know which rule fired and where&lt;/em&gt;. The hard part is knowing, before the 400.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;5-the-cross-provider-trap&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;5. The cross-provider trap&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;OpenAI is the loudest case, but the deeper problem is that &lt;strong class=&quot;font-semibold text-white&quot;&gt;the five surfaces disagree&lt;/strong&gt;, so fixing for one can break another:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Anthropic&lt;/strong&gt; rejects a set of validation keywords in tool input schemas, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;format&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;minLength&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;pattern&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;minimum&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;maximum&lt;/code&gt;, that OpenAI and others accept. A schema you tightened for OpenAI can trip Anthropic.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Gemini&lt;/strong&gt; has no &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;anyOf&lt;/code&gt;/union support in the same way and chokes on self-referential &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;$ref&lt;/code&gt; (recursion), a common shape when a Pydantic model references itself. In the corpus these showed up as warnings, not hard errors, but they&amp;#x27;re real runtime risks.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Cohere&lt;/strong&gt; wants at least one required field on every object.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Mistral&lt;/strong&gt;&amp;#x27;s structured-output examples set &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt; everywhere, like OpenAI.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is why only 3 of 50 schemas passed all five. There is no single &amp;quot;correct&amp;quot; schema, there&amp;#x27;s a per-provider constraint surface, and a schema that ships to multiple providers has to satisfy the &lt;em&gt;intersection&lt;/em&gt;.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;6-catch-it-in-ci-not-in-prod&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;6. Catch it in CI, not in prod&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fix for an opaque, multi-surface, easy-to-regress constraint is the same fix we use for every other class of &amp;quot;works on my machine&amp;quot; bug: &lt;strong class=&quot;font-semibold text-white&quot;&gt;a static check that fails the build.&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That&amp;#x27;s the entire premise of &lt;a href=&quot;https://github.com/OrionArchitekton/schemafit&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-copper-400 underline decoration-copper-500/40 underline-offset-2 hover:text-copper-300&quot;&gt;schemafit&lt;/a&gt;, the tool I ran this study with. It encodes each provider&amp;#x27;s documented constraint surface as a versioned rule pack and lints your schema statically: exact JSON-Pointer path, the keyword, and why, with a non-zero exit code so CI fails the PR instead of prod. It makes no model calls, needs no API key, and has zero runtime dependencies.&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;pip install schemafit
schemafit lint my-schema.json --provider openai,anthropic,gemini,mistral,cohere&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;It ships a GitHub Action and a pre-commit hook, and emits SARIF for GitHub code-scanning. But the tool is incidental to the lesson: &lt;strong class=&quot;font-semibold text-white&quot;&gt;treat provider schema constraints as a CI concern, the way you treat types or lint.&lt;/strong&gt; Whether you adopt a linter, a test, or a checklist, the move is to surface the 400 &lt;em&gt;before&lt;/em&gt; it reaches a provider.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;7-honesty-notes-because-the-numbers-will-get-scrutinized&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;7. Honesty notes (because the numbers will get scrutinized)&lt;/h2&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;OpenAI&amp;#x27;s 88% is the firm case.&lt;/strong&gt; Strict Structured Outputs &lt;em&gt;documents&lt;/em&gt; the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt; and all-required requirements; the schemas that passed OpenAI genuinely carried both. That number is solid.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Mistral&amp;#x27;s 88% uses a more conservative rule.&lt;/strong&gt; Mistral&amp;#x27;s docs &lt;em&gt;show&lt;/em&gt; &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;additionalProperties: false&lt;/code&gt; in their structured-output examples but don&amp;#x27;t prose-mandate it, so read Mistral as &amp;quot;likely-reject,&amp;quot; not &amp;quot;documented-reject.&amp;quot;&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Gemini&amp;#x27;s hits were warnings, not errors&lt;/strong&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;$ref&lt;/code&gt; recursion and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;anyOf&lt;/code&gt; are real risks, but I&amp;#x27;m not claiming a guaranteed 400.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The corpus is provider-mixed by design&lt;/strong&gt;, cookbook examples skew already-strict; framework and MCP schemas skew permissive, so 88% is a floor, not a cherry-pick.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The full per-provider breakdown, the provenance for all 50 schemas (every source URL), and a script to reproduce the run on your own schema directory are in the repo&amp;#x27;s &lt;a href=&quot;https://github.com/OrionArchitekton/schemafit/tree/main/benchmarks&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-copper-400 underline decoration-copper-500/40 underline-offset-2 hover:text-copper-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;benchmarks/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;the-takeaway&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;The takeaway&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;LLM schemas aren&amp;#x27;t portable, and the providers won&amp;#x27;t tell you why a schema failed, only that it did. The constraint surfaces are documented but scattered, opaque at the API boundary, and easy to regress when you fix for one provider and break another. The 88% number isn&amp;#x27;t an indictment of anyone&amp;#x27;s schemas; it&amp;#x27;s evidence that this belongs in CI, next to your other static checks, not in a production traceback at 2am.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Run it on your own schemas and see where you land. If you hit a rejection a linter &lt;em&gt;doesn&amp;#x27;t&lt;/em&gt; catch, or a rule that&amp;#x27;s wrong for your setup, that&amp;#x27;s exactly the feedback worth sharing.&lt;/p&gt;</content:encoded></item>
<item><title>Self-Correcting Agents: Learning the Loop the Hard Way</title><link>https://www.danmercede.com/guides/verifier-abstention-not-refutation</link><guid isPermaLink="true">https://www.danmercede.com/guides/verifier-abstention-not-refutation</guid><pubDate>Sat, 27 Jun 2026 07:00:00 +0000</pubDate><description>In self-correcting agent loops, a crashed verifier gets read as a refutation, and the loop quietly throws away true results. The three-state gate design and recovery that prevent it.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;I ran a multi-agent research agent over a hard question and it came back with a clean, confident verdict: &lt;strong class=&quot;font-semibold text-white&quot;&gt;&amp;quot;All 25 claims refuted by adversarial verification. Research inconclusive.&amp;quot;&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Every one of those 25 claims was true. Several cited real, recent papers I could pull up in another tab. Nothing had actually been checked.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The verifier hadn&amp;#x27;t &lt;em&gt;disagreed&lt;/em&gt; with the research. It had &lt;strong class=&quot;font-semibold text-white&quot;&gt;crashed&lt;/strong&gt;, every verification call hit transient API rate-limiting and returned nothing, and the loop scored &amp;quot;returned nothing&amp;quot; as &amp;quot;refuted.&amp;quot; A fail-closed gate that cannot tell &lt;em&gt;refuted by evidence&lt;/em&gt; apart from &lt;em&gt;never adjudicated&lt;/em&gt; will lie to you with total confidence, and dress the lie up as diligence.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide is about that failure mode: why it happens, why it&amp;#x27;s the &lt;em&gt;default&lt;/em&gt; outcome unless you design against it, how to spot it in your own loops, the gate that prevents it, and how to recover a run that has already produced a false &amp;quot;everything is false.&amp;quot;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; anyone building agent loops that check their own work, research agents, code agents with a review pass, anything with a &amp;quot;generate → verify → keep or discard&amp;quot; cycle. You don&amp;#x27;t need a specific framework. You need to take one idea seriously: &lt;strong class=&quot;font-semibold text-white&quot;&gt;silence is not a verdict.&lt;/strong&gt;&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;1-the-anatomy-of-a-self-checking-loop&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;1. The anatomy of a self-checking loop&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Almost every serious agent loop has the same three roles, whatever you call them. The reasoning-frontiers survey of LLM reasoning fixes the vocabulary (arXiv:2504.09037):&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A &lt;strong class=&quot;font-semibold text-white&quot;&gt;Reasoner&lt;/strong&gt; generates candidate answers (the policy).&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;One or more &lt;strong class=&quot;font-semibold text-white&quot;&gt;Verifiers&lt;/strong&gt; evaluate them (the judge / reward signal).&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A &lt;strong class=&quot;font-semibold text-white&quot;&gt;Refiner&lt;/strong&gt; revises based on verifier feedback.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;All three can be the same model wearing different hats, that&amp;#x27;s exactly what &amp;quot;self-refinement&amp;quot; is. The research agent that lied to me was a fan-out version of this:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-text&quot;&gt;scope ──► search ──► fetch ──► extract claims ──► VERIFY (3-vote panel per claim) ──► synthesize
                                                      │
                                                      └─ each claim needs a quorum of votes to survive&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The verify stage is the load-bearing one. It&amp;#x27;s where the loop decides what&amp;#x27;s &lt;em&gt;true enough to keep&lt;/em&gt;. And it&amp;#x27;s the stage everyone gets wrong, because it&amp;#x27;s the stage where the failure is invisible: a broken Reasoner produces obvious garbage, but a broken &lt;strong class=&quot;font-semibold text-white&quot;&gt;Verifier&lt;/strong&gt; produces clean, plausible, wrong verdicts.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;2-the-bug-a-two-state-gate-in-a-three-state-world&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;2. The bug: a two-state gate in a three-state world&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here&amp;#x27;s the gate that shipped in the loop that failed me. Each claim got three independent verifier votes, tallied as &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;notRefuted, refuted&lt;/code&gt;. The survive rule was reasonable on its face:&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A claim survives if it has &lt;strong class=&quot;font-semibold text-white&quot;&gt;at least 2 valid votes&lt;/strong&gt; and &lt;strong class=&quot;font-semibold text-white&quot;&gt;fewer than 2 refutations.&lt;/strong&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Read that rule carefully. It quietly assumes votes &lt;em&gt;exist&lt;/em&gt;. When the verify phase crashed under rate-limiting, every vote came back empty. Each claim scored &lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0-0&lt;/code&gt;&lt;/strong&gt;: zero refutations, but also zero valid votes. The survive rule said &lt;em&gt;&amp;quot;fewer than 2 refutations? yes. At least 2 valid votes? no.&amp;quot;&lt;/em&gt;, so the claim didn&amp;#x27;t survive.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So far, so correct. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Refusing to pass an unverified claim is good design.&lt;/strong&gt; The bug was one layer up, in the &lt;em&gt;reporting&lt;/em&gt;: the claims that didn&amp;#x27;t survive were dumped into a bucket literally named &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;refuted[]&lt;/code&gt;, and the summary rendered that bucket as &lt;em&gt;&amp;quot;all 25 claims refuted, research inconclusive.&amp;quot;&lt;/em&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The gate had collapsed a &lt;strong class=&quot;font-semibold text-white&quot;&gt;three-state world into two states:&lt;/strong&gt;&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/verifier-abstention/three-verdict-model.webp&quot; alt=&quot;A claim&amp;#x27;s verifier outcome has three states, CONFIRMED, REFUTED, and ABSTAINED, but a naive gate sorts every claim into just CONFIRMED or REFUTED, dropping ABSTAINED silently into the refuted bucket.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;A verifier vote has three outcomes, not two. Folding ABSTAINED into REFUTED is how a crash becomes a confident &amp;#x27;no.&amp;#x27;&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The world has &lt;strong class=&quot;font-semibold text-white&quot;&gt;three&lt;/strong&gt; outcomes, and you must keep them distinct:&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Outcome&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Meaning&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;What it tells you&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;CONFIRMED&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;quorum of valid votes, below the refute threshold&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;keep it&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;REFUTED&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;quorum of valid votes, at/above the refute threshold&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;drop it, &lt;em&gt;the evidence says no&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;ABSTAINED&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;below quorum of &lt;em&gt;valid&lt;/em&gt; votes (crash, timeout, null, rate-limit)&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;you don&amp;#x27;t know yet&lt;/strong&gt;, re-run, escalate, or surface as pending&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The one rule this whole guide rests on:&lt;/strong&gt; an absence of votes is not a vote. &lt;em&gt;Refuted&lt;/em&gt; is a verdict the evidence reached; &lt;em&gt;abstained&lt;/em&gt; is a verdict the evidence never reached. A gate that conflates them converts every transient outage into a wall of false negatives, and because it fails &lt;em&gt;closed&lt;/em&gt;, it looks responsible while doing it.&lt;/p&gt;&lt;/blockquote&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;3-why-this-is-the-default-not-an-edge-case&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;3. Why this is the default, not an edge case&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You might think this is an exotic bug. It isn&amp;#x27;t, it&amp;#x27;s what you get &lt;em&gt;unless you specifically prevent it&lt;/em&gt;, for three compounding reasons.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Fail-closed is the right instinct, and that&amp;#x27;s the trap.&lt;/strong&gt; You &lt;em&gt;should&lt;/em&gt; refuse to pass a claim you couldn&amp;#x27;t verify. Every safety-conscious engineer builds the gate to deny on uncertainty. The problem is that &amp;quot;deny&amp;quot; and &amp;quot;refute&amp;quot; land in the same bucket unless you force them apart. The safer your instinct, the more likely you are to mislabel silence as rejection.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Verifiers fail in correlated bursts.&lt;/strong&gt; A single flaky vote is noise you&amp;#x27;d shrug off. But verifier calls share infrastructure, the same API, the same rate limiter, the same network. When one abstains from a transient limit, they &lt;em&gt;all&lt;/em&gt; do, at once. So you don&amp;#x27;t get one mislabeled claim; you get &lt;em&gt;the whole run&lt;/em&gt; mislabeled in a single stroke. The blast radius is the entire output.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The summary layer launders the error.&lt;/strong&gt; The gate&amp;#x27;s internal state might still be technically recoverable (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0-0&lt;/code&gt; is visibly different from &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0-2&lt;/code&gt;). But by the time it reaches a one-line summary, &lt;em&gt;&amp;quot;research inconclusive&amp;quot;&lt;/em&gt;, the distinction is gone. A human reading the summary has no way to tell a crash from a conclusion. The error becomes load-bearing for every downstream decision.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is a specific, nasty instance of a general truth the research keeps surfacing: *&lt;em&gt;agents are far better at &lt;/em&gt;producing&lt;em&gt; a good answer than at &lt;/em&gt;recognizing&lt;em&gt; one.&lt;/em&gt;* The verification step is the weakest link in the loop, so it deserves the most defensive design, not the least.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;4-the-research-verification-is-the-bottleneck-not-generation&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;4. The research: verification is the bottleneck, not generation&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Step back from the bug and look at why the &lt;em&gt;verify&lt;/em&gt; stage is where loops live or die.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The verification gap.&lt;/strong&gt; A 2026 benchmark of general LLM agents put a name to the failure (arXiv:2602.18998): when you sample several candidate answers, the &lt;em&gt;right&lt;/em&gt; one is often already in your set, your pass@K is high, because the model &lt;em&gt;generated&lt;/em&gt; it, but the model &lt;strong class=&quot;font-semibold text-white&quot;&gt;can&amp;#x27;t reliably select it&lt;/strong&gt;, so the accuracy you actually realize lags far behind the answer you already have. Generation outruns judgment. This is the structural reason &amp;quot;just let the agent double-check itself&amp;quot; underdelivers, and the reason verifier quality dominates loop quality.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The self-correction blind spot.&lt;/strong&gt; It gets sharper. &lt;em&gt;Self-Correction Bench&lt;/em&gt; measured an average &lt;strong class=&quot;font-semibold text-white&quot;&gt;64.5% blind-spot rate across 14 models&lt;/strong&gt; (arXiv:2507.02778): models readily fix an error when it&amp;#x27;s pointed out in the &lt;em&gt;prompt&lt;/em&gt;, but systematically fail to fix the &lt;em&gt;same&lt;/em&gt; error in &lt;em&gt;their own&lt;/em&gt; prior output. The likely cause is mundane, training data is overwhelmingly composed of clean, correct demonstrations, not error-then-correction traces, so the &amp;quot;notice and revise my own mistake&amp;quot; behavior is under-trained. The striking part: &lt;strong class=&quot;font-semibold text-white&quot;&gt;simply appending the word &amp;quot;Wait&amp;quot; cut blind spots by 89.3%.&lt;/strong&gt; The capability is there; it just doesn&amp;#x27;t fire on its own.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Put those two findings together and the design conclusion is blunt: &lt;strong class=&quot;font-semibold text-white&quot;&gt;a loop that relies on a single model to silently self-judge is building on the part of the system that is measurably worst at the job.&lt;/strong&gt; You cannot fix that with more self-reflection. You fix it with &lt;em&gt;more, and more independent, verification&lt;/em&gt;, which is exactly why a verifier that quietly drops to zero is so destructive. It removes the one thing that was compensating for the model&amp;#x27;s blind spot, and it does so invisibly.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Verification is its own scaling axis.&lt;/strong&gt; The frontier response to the verification gap is to &lt;strong class=&quot;font-semibold text-white&quot;&gt;scale the number of verifiers&lt;/strong&gt;, not the cleverness of one. Multi-Agent Verification (arXiv:2502.20379) uses off-the-shelf models as independent &amp;quot;Aspect Verifiers&amp;quot; that vote, no training required, and shows &lt;em&gt;weak-to-strong generalization&lt;/em&gt;: a panel of weaker verifiers can improve even a stronger generator. Rubric-grounded test-time verification (DeepVerifier, arXiv:2601.15808) extends the same idea to research agents, plug-and-play with no fine-tuning. The whole point of these systems is to make the verdict &lt;em&gt;robust&lt;/em&gt;. A panel that silently abstains &lt;em&gt;en masse&lt;/em&gt; is the precise opposite, it&amp;#x27;s a quorum that quietly dropped below quorum without telling anyone.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;5-the-fix-design-the-gate-around-three-states&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;5. The fix: design the gate around three states&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The fix is not subtle once you&amp;#x27;ve named the problem. &lt;strong class=&quot;font-semibold text-white&quot;&gt;Make ABSTAINED a first-class state and never let it masquerade as either pass or fail.&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Three rules implement it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;*&lt;em&gt;Rule 1, classify on &lt;/em&gt;valid&lt;em&gt; votes, not on net score.&lt;/em&gt;&lt;em&gt; A vote only counts toward a verdict if the verifier actually ran and returned a parseable judgment. Before you tally &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;notRefuted&lt;/code&gt; vs &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;refuted&lt;/code&gt;, count how many votes are &lt;/em&gt;valid at all*. If valid votes are below your quorum, the outcome is &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABSTAINED&lt;/code&gt;, full stop, you never even reach the refute comparison.&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-text&quot;&gt;for each claim:
    valid   = votes that actually ran and parsed
    refuted = valid votes that say &amp;quot;refuted&amp;quot;
    if len(valid) &amp;lt; QUORUM:                -&amp;gt; ABSTAINED   (not adjudicated)
    elif len(refuted) &amp;gt;= REFUTE_THRESHOLD: -&amp;gt; REFUTED     (evidence says no)
    else:                                  -&amp;gt; CONFIRMED    (keep)&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;em&gt;For the 3-vote panel from §2, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;QUORUM = 2&lt;/code&gt; and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;REFUTE_THRESHOLD = 2&lt;/code&gt;, the very same thresholds as the broken gate. Nothing about the numbers changed; only the &lt;/em&gt;&lt;em&gt;order&lt;/em&gt;&lt;em&gt; of the checks and the &lt;/em&gt;&lt;em&gt;keying on valid-vote count&lt;/em&gt;&lt;em&gt; did.&lt;/em&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The single change from the broken gate is the &lt;strong class=&quot;font-semibold text-white&quot;&gt;first branch runs first&lt;/strong&gt; and is keyed on &lt;em&gt;valid-vote count&lt;/em&gt;, not on the refutation count. A &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0-0&lt;/code&gt; claim can never be reported as refuted because it exits at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABSTAINED&lt;/code&gt; before the refutation branch is ever evaluated.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Rule 2, an error, timeout, or null is an abstention, and abstentions get retried, never counted clean.&lt;/strong&gt; A verifier invocation that throws, times out, or returns nothing is a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;PENDING&lt;/code&gt; abstention, re-run it up to &lt;em&gt;N&lt;/em&gt; times with backoff. Critically, an abstention is &lt;strong class=&quot;font-semibold text-white&quot;&gt;never&lt;/strong&gt; silently treated as a pass &lt;em&gt;or&lt;/em&gt; a fail. (This is the same discipline a good code-review gate needs: a reviewer subagent that errors out hasn&amp;#x27;t &lt;em&gt;approved&lt;/em&gt; the diff, treating &amp;quot;the review crashed&amp;quot; as &amp;quot;the review is clean&amp;quot; is the same bug wearing a different shirt.)&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/verifier-abstention/abstention-tolerant-gate.webp&quot; alt=&quot;An abstention-tolerant gate: a verifier vote is first checked for validity; invalid or missing votes route to a retry-up-to-N loop, and only a genuine quorum of valid votes produces a CONFIRMED or REFUTED verdict, while exhausted retries surface as PENDING, never as clean.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;The retry loop sits between &amp;#x27;vote arrived&amp;#x27; and &amp;#x27;verdict counted.&amp;#x27; Exhausted retries surface as PENDING, never as pass or refute.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Rule 3, never transcribe &amp;quot;inconclusive&amp;quot; as a finding without checking whether the verifiers ran.&lt;/strong&gt; This is the reporting-layer rule, and it&amp;#x27;s the one that actually saved me. Before a summary says &lt;em&gt;&amp;quot;all refuted&amp;quot;&lt;/em&gt; or &lt;em&gt;&amp;quot;inconclusive,&amp;quot;&lt;/em&gt; it must inspect the &lt;em&gt;shape&lt;/em&gt; of the votes that produced that verdict. If the run is dominated by &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0-0&lt;/code&gt; abstentions, the honest summary is &lt;strong class=&quot;font-semibold text-white&quot;&gt;&amp;quot;verification did not complete&amp;quot;&lt;/strong&gt;, an operational failure to retry, not &lt;strong class=&quot;font-semibold text-white&quot;&gt;&amp;quot;the claims are false,&amp;quot;&lt;/strong&gt; a substantive finding. A crashed adjudicator&amp;#x27;s silence must never be rendered as a conclusion.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A compact way to enforce Rule 3 is a tiny, &lt;em&gt;report-only&lt;/em&gt; reclassifier that you can run over any saved run output:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-text&quot;&gt;reclassify(run_output):
    for each claim in run_output.refuted_bucket:
        valid = parse_vote_tally(claim)
        if valid &amp;lt; QUORUM:  -&amp;gt;  ABSTAINED   (recoverable; the run never judged it)
        else:               -&amp;gt;  REFUTED     (a real verdict)
    if ABSTAINED dominates:  print &amp;quot;ABSTENTION-DOMINATED, the run did not refute anything&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;(One naming note so the labels don&amp;#x27;t trip you: &lt;strong class=&quot;font-semibold text-white&quot;&gt;ABSTAINED&lt;/strong&gt; is the terminal &amp;quot;not adjudicated&amp;quot; state, the same one a run&amp;#x27;s output later shows as &amp;quot;unverified&amp;quot;; &lt;strong class=&quot;font-semibold text-white&quot;&gt;PENDING&lt;/strong&gt; is just its mid-retry sub-state. One concept, named once.) It changes nothing and runs nothing, it just re-reads the tallies and tells you whether your &amp;quot;all refuted&amp;quot; was a verdict or a crash. That single check is the difference between trusting a false negative and catching it.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;6-detecting-it-in-a-loop-you-already-have&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;6. Detecting it in a loop you already have&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You can audit an existing loop for this failure without rebuilding it. The tells:&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Symptom&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;What it usually means&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;A verify/review stage reports &lt;strong class=&quot;font-semibold text-white&quot;&gt;everything&lt;/strong&gt; failed at once&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;correlated abstention (shared rate limit / outage), not a real mass rejection&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Verdicts flip between runs with no input change&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;the gate is scoring transient failures as outcomes&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;The &amp;quot;failed&amp;quot; bucket has no &lt;em&gt;per-item evidence&lt;/em&gt; (no refuting quote, no reason)&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;those items were never adjudicated, there&amp;#x27;s nothing to cite because nothing ran&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;A summary says &amp;quot;inconclusive&amp;quot; but the underlying items cite real, checkable sources&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;the loop discarded true results; verification, not the research, is what failed&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Tightening a rate limit or shrinking a batch makes the &amp;quot;failure rate&amp;quot; drop&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;you&amp;#x27;re measuring infrastructure, not correctness&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The deepest tell is the absence of &lt;em&gt;evidence&lt;/em&gt; attached to a negative verdict. A genuine refutation can point at &lt;em&gt;why&lt;/em&gt;, a contradicting source, a failing assertion, a counterexample. An abstention can&amp;#x27;t, because nothing happened. &lt;strong class=&quot;font-semibold text-white&quot;&gt;If your &amp;quot;no&amp;quot; can&amp;#x27;t show its work, it isn&amp;#x27;t a no.&lt;/strong&gt;&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;7-recovery-resume-don-t-re-run&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;7. Recovery: resume, don&amp;#x27;t re-run&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Say it already happened, a run produced a false &amp;quot;everything refuted.&amp;quot; The instinct is to run the whole expensive pipeline again from scratch, and in a real fan-out (mine spanned several stages and a couple of dozen sources), &amp;quot;from scratch&amp;quot; is a lot of wasted minutes and tokens. Don&amp;#x27;t. Most of that run is &lt;em&gt;fine&lt;/em&gt; and recoverable. Recover in three steps, cheapest first.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/verifier-abstention/recovery-flow.webp&quot; alt=&quot;Recovery flow: first reclassify the saved output to confirm the failure was abstention-dominated, then resume the run from its cached prefix so only the crashed verifier calls re-run, then do a gentle sequential top-up for any remaining tail.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Recovery is cheapest-first: reclassify (seconds) → resume from cache (only the crashed calls re-run) → sequential top-up for the tail.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Step 1, reclassify (seconds, changes nothing).&lt;/strong&gt; Run the report-only reclassifier from §5 over the saved output. It splits the &amp;quot;refuted&amp;quot; pile into &lt;em&gt;genuinely refuted&lt;/em&gt; (a real quorum said no) versus &lt;em&gt;abstained&lt;/em&gt; (below quorum, recoverable). If it comes back &lt;strong class=&quot;font-semibold text-white&quot;&gt;abstention-dominated&lt;/strong&gt;, you now know the research itself is intact; only the verification failed. You&amp;#x27;ve turned a false catastrophe into a known, bounded gap.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Step 2, resume from the cached prefix; don&amp;#x27;t restart.&lt;/strong&gt; A good loop harness caches completed stages. The scope, the search, the fetch, and &lt;em&gt;every verifier vote that actually succeeded&lt;/em&gt; are still valid, re-running them wastes time and burns the same rate limit that caused the failure. Resume the run so that &lt;strong class=&quot;font-semibold text-white&quot;&gt;only the abstained verifier calls re-execute.&lt;/strong&gt; You pay for the gap, not the whole pipeline.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Step 3, top up the tail by hand, gently.&lt;/strong&gt; For the handful of items still unverified after the resume, verify them sequentially, one source at a time, at a calm cadence. The shared rate limiter that caused the crash &lt;em&gt;eases over time&lt;/em&gt;; a gentle, serial top-up slips under it where a parallel burst re-trips it. This is slow on purpose. Slow-and-complete beats fast-and-false.&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A note on &amp;quot;future-dated&amp;quot; citations:&lt;/strong&gt; when you re-verify, resist the urge to auto-reject a source just because it looks improbable, a paper with a publication date a few months out, say. In my run, several &amp;quot;suspicious&amp;quot; citations resolved to entirely real papers on inspection. &lt;em&gt;Existence-check before you refute.&lt;/em&gt; That, too, is the difference between a verdict and a guess.&lt;/p&gt;&lt;/blockquote&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;8-the-broader-principle-spend-compute-on-generation-and-external-verification&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;8. The broader principle: spend compute on generation and external verification&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Zoom all the way out. The abstention bug is a sharp instance of a general lesson about &lt;em&gt;where inference compute pays off&lt;/em&gt; in loops, and the evidence is increasingly clear that the popular instinct is backwards.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Brute repetition is the weak lever; verification is the binding constraint.&lt;/strong&gt; Spending more compute by sampling several independent trajectories and selecting among them &lt;em&gt;can&lt;/em&gt; help, the systematic study of test-time scaling for agents finds that scaling test-time compute, diversifying rollouts, and list-wise verification all move the needle (arXiv:2506.12928). But the payoff is far from automatic: a 2026 benchmark of &lt;em&gt;general&lt;/em&gt; LLM agents found that &lt;strong class=&quot;font-semibold text-white&quot;&gt;neither&lt;/strong&gt; parallel sampling &lt;strong class=&quot;font-semibold text-white&quot;&gt;nor&lt;/strong&gt; longer sequential reasoning reliably improved performance, sequential scaling hit an &lt;strong class=&quot;font-semibold text-white&quot;&gt;&amp;quot;effective context ceiling,&amp;quot;&lt;/strong&gt; and parallel scaling was capped by &lt;strong class=&quot;font-semibold text-white&quot;&gt;the verification gap itself&lt;/strong&gt; (arXiv:2602.18998). Read that twice: the thing throttling parallel sampling is the &lt;em&gt;same&lt;/em&gt; selection problem this whole guide is about. The right answer is often already in your samples; the agent just can&amp;#x27;t pick it, and letting one agent simply &amp;quot;think longer&amp;quot; is the weakest lever of all.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Reflect selectively, not constantly.&lt;/strong&gt; When you do refine, trigger it &lt;em&gt;only&lt;/em&gt; when a step&amp;#x27;s score falls below a threshold, rather than reflecting at every step, knowing &lt;em&gt;when&lt;/em&gt; to reflect beats reflecting often (arXiv:2506.12928). And self-verify-then-correct loops (SETS, arXiv:2501.19306) keep improving with compute where plain best-of-N plus majority vote saturates. The throughline: &lt;strong class=&quot;font-semibold text-white&quot;&gt;structured selectivity and stronger verification scale; brute repetition doesn&amp;#x27;t.&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Now connect that back to the bug. If parallel generation puts the right answer in your candidate set, and the verification gap means selecting it is the hard part, then your &lt;strong class=&quot;font-semibold text-white&quot;&gt;verifier is the highest-leverage component in the entire loop.&lt;/strong&gt; Which is exactly why a verifier that can silently fall to zero, and report that zero as a confident &amp;quot;no&amp;quot;, is the most expensive bug you can ship. It doesn&amp;#x27;t just add noise; it removes the one mechanism that was carrying the loop, and it hides the removal.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The design conclusion, stated as a single sentence: &lt;strong class=&quot;font-semibold text-white&quot;&gt;spend your inference budget on diverse generation plus stronger, independent, external verification, and build that verification so that when it fails, it says &amp;quot;I didn&amp;#x27;t check,&amp;quot; never &amp;quot;the answer is no.&amp;quot;&lt;/strong&gt;&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;9-checklist-an-honest-verification-gate&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;9. Checklist: an honest verification gate&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Use this when you build or audit a self-checking loop.&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Three states, always.&lt;/strong&gt; Every verdict is &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;CONFIRMED&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;REFUTED&lt;/code&gt;, or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABSTAINED&lt;/code&gt;. There is no fourth bucket, and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABSTAINED&lt;/code&gt; is never quietly merged into either of the others.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] *&lt;em&gt;Classify on &lt;/em&gt;valid&lt;em&gt; votes first.&lt;/em&gt;&lt;em&gt; Count votes that actually ran and parsed &lt;/em&gt;before* you compare refutations. Below quorum of valid votes ⇒ &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ABSTAINED&lt;/code&gt;, and you exit before the refute branch.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Errors/timeouts/nulls are abstentions.&lt;/strong&gt; A verifier that throws, times out, or returns nothing is &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;PENDING&lt;/code&gt;, retry up to &lt;em&gt;N&lt;/em&gt; with backoff. It is &lt;strong class=&quot;font-semibold text-white&quot;&gt;never&lt;/strong&gt; counted as clean and &lt;strong class=&quot;font-semibold text-white&quot;&gt;never&lt;/strong&gt; counted as refuted.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;A negative verdict must show evidence.&lt;/strong&gt; A real &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;REFUTED&lt;/code&gt; cites &lt;em&gt;why&lt;/em&gt; (a contradicting source, a failing check). If a &amp;quot;no&amp;quot; has no attached evidence, treat it as an abstention until proven otherwise.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Guard the summary layer.&lt;/strong&gt; Before any &amp;quot;inconclusive&amp;quot;/&amp;quot;all refuted&amp;quot; summary ships, inspect the vote shape. Abstention-dominated ⇒ report &amp;quot;verification did not complete,&amp;quot; not &amp;quot;the claims are false.&amp;quot;&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Keep a report-only reclassifier.&lt;/strong&gt; A tiny tool that re-reads a saved run&amp;#x27;s tallies and splits &lt;em&gt;refuted&lt;/em&gt; from &lt;em&gt;unverified&lt;/em&gt; turns a silent false-negative into a one-command catch.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Recover by resume, not restart.&lt;/strong&gt; Cache completed stages and successful votes; re-run only the abstained calls. Top up the tail sequentially and gently so you don&amp;#x27;t re-trip the limiter.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Watch for correlated failure.&lt;/strong&gt; If a whole batch &amp;quot;fails&amp;quot; at once, suspect shared infrastructure (rate limit, outage) before you believe a mass rejection.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The discipline behind every line is one sentence worth memorizing: &lt;strong class=&quot;font-semibold text-white&quot;&gt;a verifier&amp;#x27;s silence is missing data, not a &amp;quot;no.&amp;quot;&lt;/strong&gt; Build your loop so it can tell the difference, and it will fail honestly, which is the only kind of failure you can recover from.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Further reading:&lt;/strong&gt; the systematic treatment of test-time scaling for &lt;em&gt;agents&lt;/em&gt; (arXiv:2506.12928) and the 2026 benchmark of general agents that finds brute test-time scaling underdelivers, naming the context ceiling and the verification gap (arXiv:2602.18998); Multi-Agent Verification / Aspect Verifiers (arXiv:2502.20379) and rubric-guided test-time verification (arXiv:2601.15808) for verification as its own scaling axis; &lt;em&gt;Self-Correction Bench&lt;/em&gt; for the 64.5% blind spot and the &amp;quot;Wait&amp;quot; activation (arXiv:2507.02778); SETS for self-verify-and-correct loops (arXiv:2501.19306); and the reasoning-frontiers survey for the Reasoner/Verifier/Refiner vocabulary (arXiv:2504.09037).&lt;/p&gt;</content:encoded></item>
<item><title>Self-Hosting Many Apps on One Box: A Beginner&#39;s Guide to the Two-Plane Architecture</title><link>https://www.danmercede.com/guides/self-hosting-websites-and-apps</link><guid isPermaLink="true">https://www.danmercede.com/guides/self-hosting-websites-and-apps</guid><pubDate>Thu, 25 Jun 2026 07:00:00 +0000</pubDate><description>Run many websites and apps on one server safely: the two-plane architecture with Cloudflare Tunnel, Traefik, and Tailscale, zero public inbound ports, a private admin mesh.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If you&amp;#x27;ve ever tried to run your own apps, a dashboard, wiki, side-project API, or metrics stack, on a single machine you control, you&amp;#x27;ve probably hit the wall everyone hits: self-hosting gets messy fast. A port opened here, a hand-written nginx file there, a forgotten admin panel, a leaked origin IP, and suddenly your &amp;quot;one box&amp;quot; is an attack surface you can&amp;#x27;t reason about.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This guide gives you a clean, defensible architecture instead. By the end, the recommended path gives you *&lt;em&gt;one box, one reverse proxy, zero &lt;/em&gt;public&lt;em&gt; inbound ports, and a private mesh for everything you administer&lt;/em&gt;&lt;em&gt;, and a mental model that scales from one app to dozens without the security story getting worse. (&amp;quot;Zero &lt;/em&gt;public&lt;em&gt; inbound ports&amp;quot; is the precise claim: Traefik still listens &lt;/em&gt;inside* the box; nothing is published to the public internet.)&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who this is for:&lt;/strong&gt; a capable beginner comfortable with Docker, a terminal, and editing YAML. You don&amp;#x27;t need to be a network engineer. You do need to be willing to think in terms of &lt;em&gt;planes&lt;/em&gt; instead of ports.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;1-why-self-host-and-what-you-re-actually-signing-up-for&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;1. Why self-host (and what you&amp;#x27;re actually signing up for)&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The payoff is real: &lt;strong class=&quot;font-semibold text-white&quot;&gt;full control, no per-seat SaaS bills, your data on your own box, and the ability to run many apps on one machine.&lt;/strong&gt; You also learn how the internet actually works, DNS, TLS, proxies, VPNs, which pays dividends forever.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The honest cost: &lt;strong class=&quot;font-semibold text-white&quot;&gt;you are now the ops team.&lt;/strong&gt; Uptime, TLS renewal, backups, and security are yours. This guide &lt;em&gt;minimizes&lt;/em&gt; that burden; it doesn&amp;#x27;t erase it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The naive approach, and why it fails: you open a firewall port per app, hand-write a vhost per app, and point public DNS straight at your home or VPS IP. This &lt;strong class=&quot;font-semibold text-white&quot;&gt;sprawls&lt;/strong&gt; (every app is a config island), &lt;strong class=&quot;font-semibold text-white&quot;&gt;leaks your origin IP&lt;/strong&gt; (now you&amp;#x27;re a direct DDoS and scan target), and &lt;strong class=&quot;font-semibold text-white&quot;&gt;gives an attacker one open port per service&lt;/strong&gt; to probe. Three apps, three ports, three things that can be misconfigured into a breach.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/naive-vs-two-plane-comparison.webp&quot; alt=&quot;Comparison of naive self-hosting with separate public ports for apps, SSH, Grafana, and databases versus a two-plane setup using Cloudflare, Traefik, and Tailscale.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Naive self-hosting exposes many doors; the two-plane model exposes one controlled front door and keeps admin access private.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;What we build instead: a single reverse proxy as the only public front door, a tunnel so you open &lt;strong class=&quot;font-semibold text-white&quot;&gt;zero&lt;/strong&gt; inbound ports, and a private mesh VPN for every surface only &lt;em&gt;you&lt;/em&gt; should touch.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;2-the-mental-model-two-planes&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;2. The mental model: two planes&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here is the single idea the whole guide rests on. Every service you run answers &lt;strong class=&quot;font-semibold text-white&quot;&gt;one question&lt;/strong&gt;:&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Does the public need to reach this?&lt;/p&gt;&lt;/blockquote&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Yes&lt;/strong&gt; → it lives on the &lt;strong class=&quot;font-semibold text-white&quot;&gt;PUBLIC INGRESS plane&lt;/strong&gt;. (Your apps, your APIs, anything a visitor uses.)&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;No&lt;/strong&gt; → it lives on the &lt;strong class=&quot;font-semibold text-white&quot;&gt;PRIVATE ADMIN plane&lt;/strong&gt;. (Dashboards, metrics, databases, secrets stores, SSH, everything &lt;em&gt;you&lt;/em&gt; touch as the operator.)&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;These two planes have &lt;strong class=&quot;font-semibold text-white&quot;&gt;separate entry points, separate failure domains, and separate trust models.&lt;/strong&gt; That separation &lt;em&gt;is&lt;/em&gt; the security story: a misconfigured public route cannot expose an admin tool that has no public listener at all.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The load-bearing rule, stated up front:&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Admin/internal services bind only to loopback (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;127.0.0.1&lt;/code&gt;) or the mesh interface, never &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0.0.0.0&lt;/code&gt;.&lt;/strong&gt; You defend by &lt;em&gt;not exposing&lt;/em&gt;, not by hardening an exposed port.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0.0.0.0&lt;/code&gt; means &amp;quot;every interface, including the public one.&amp;quot; If a database only listens on the mesh, there is literally no public port for anyone to scan or brute-force.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;One thing to get right from the start: &lt;strong class=&quot;font-semibold text-white&quot;&gt;the &amp;quot;mesh interface&amp;quot; is your Tailscale interface, whose IP comes from Tailscale&amp;#x27;s CGNAT range &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;&lt;/strong&gt; (addresses &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.x&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.127.x&lt;/code&gt;), &lt;em&gt;not&lt;/em&gt; a private-LAN range like &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;10.0.0.0/8&lt;/code&gt; or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;192.168.0.0/16&lt;/code&gt;. Those LAN ranges are a different network entirely (often routable on your physical network), so allowlisting them is a security hole, not the mesh. Everywhere this guide says &amp;quot;mesh range,&amp;quot; it means &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Here&amp;#x27;s the whole architecture in one diagram:&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/two-plane-architecture.webp&quot; alt=&quot;Two-plane self-hosting architecture showing public visitors routed through Cloudflare edge, Cloudflare Tunnel, Traefik, and app containers, while operators reach SSH, dashboards, and databases through a private Tailscale mesh.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Public users enter through Cloudflare and Traefik; operators enter through the private Tailscale mesh.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Visitors take the five-layer public path. You take the mesh. &lt;strong class=&quot;font-semibold text-white&quot;&gt;The two planes meet only through explicitly declared routes, a public route for a public app, or a mesh-only route for an admin tool. No service gets both by accident.&lt;/strong&gt;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Known-good default stack&lt;/strong&gt;, keep every example matching this and you avoid the configuration drift that breaks beginner setups:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-text&quot;&gt;Recommended default for this guide
──────────────────────────────────
• cloudflared      → Docker container (same network as Traefik)
• Traefik          → Docker container, the only front door
• Public apps      → Docker containers behind Traefik
• Admin apps       → loopback or Tailscale only, never published
• Public DNS       → Cloudflare proxied hostname → Tunnel
• Public ports     → NONE published to the host&amp;#x27;s public interface&lt;/code&gt;&lt;/pre&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;3-prerequisites-and-one-time-setup&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;3. Prerequisites and one-time setup&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You&amp;#x27;ll need:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A Linux box&lt;/strong&gt; (VPS or home server) with &lt;strong class=&quot;font-semibold text-white&quot;&gt;Docker&lt;/strong&gt; and &lt;strong class=&quot;font-semibold text-white&quot;&gt;Docker Compose&lt;/strong&gt; installed.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A domain you control&lt;/strong&gt;, with DNS managed by a free &lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare&lt;/strong&gt; account.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare Zero Trust enabled&lt;/strong&gt; (the free tier covers Tunnels and Access).&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A Tailscale account&lt;/strong&gt; with the client installed on both the box and your laptop. One login enrolls a node.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A secrets habit from day one.&lt;/strong&gt; Decide where secrets live, an env file kept out of git, or a secrets manager, &lt;em&gt;before&lt;/em&gt; you wire anything. Never commit a key.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Sanity check before going further:&lt;/strong&gt; confirm &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;docker run hello-world&lt;/code&gt; works, the box has outbound internet, and you can &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ssh&lt;/code&gt; to it over Tailscale. If those three pass, you&amp;#x27;re ready.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;4-cloudflare-tunnel-reaching-the-box-with-zero-open-ports&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;4. Cloudflare Tunnel: reaching the box with zero open ports&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The problem it solves.&lt;/strong&gt; Port-forwarding is the classic way to expose a home server, but it requires an open inbound port (a permanent attack surface), publishes your origin IP, and simply doesn&amp;#x27;t work behind CGNAT or a dynamic IP.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;How it fits.&lt;/strong&gt; A small daemon, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt;, runs on your box and *&lt;em&gt;dials &lt;/em&gt;out*&lt;strong class=&quot;font-semibold text-white&quot;&gt; to Cloudflare, holding the connection open. Inbound requests ride back down that existing connection. You open &lt;/strong&gt;nothing** inbound. Your origin IP is never in public DNS. Its only requirement is outbound egress to Cloudflare, which is why it&amp;#x27;s the recommended default for beginners.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Bootstrap the tunnel (locally-managed path).&lt;/strong&gt; Before any config file means anything, you have to create the tunnel and its credentials. This sequence assumes a &lt;strong class=&quot;font-semibold text-white&quot;&gt;locally-managed&lt;/strong&gt; tunnel, created from the CLI, configured by your on-box &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt;:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# 1. Authenticate cloudflared to your Cloudflare account (opens a browser).
cloudflared tunnel login

# 2. Create the tunnel. This PRINTS the &amp;lt;tunnel-id&amp;gt; and WRITES the
#    credentials file (~/.cloudflared/&amp;lt;tunnel-id&amp;gt;.json) that config.yml points at.
cloudflared tunnel create my-box

# 3. Create the proxied CNAME for your hostname (see the DNS note below).
cloudflared tunnel route dns my-box app.example.com

# 4A. DEFAULT for this guide, run cloudflared as a Docker container on the
#     SAME network as Traefik (so the name &amp;quot;traefik&amp;quot; resolves). See the
#     cloudflared compose service below (mounts config.yml + &amp;lt;tunnel-id&amp;gt;.json).
cloudflared tunnel run my-box          # optional quick foreground test
# 4B. Host alternative, install as a systemd service. Use this ONLY if your
#     ingress routes to http://127.0.0.1:&amp;lt;port&amp;gt;, never to a Docker name.
sudo cloudflared service install&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For the default stack, run &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; as a container on the same &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;proxy&lt;/code&gt; network as Traefik:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --config /etc/cloudflared/config.yml run
    volumes:
      - ./cloudflared/config.yml:/etc/cloudflared/config.yml:ro
      - ./cloudflared/&amp;lt;tunnel-id&amp;gt;.json:/etc/cloudflared/&amp;lt;tunnel-id&amp;gt;.json:ro
    networks: [proxy]
    restart: unless-stopped

networks:
  proxy:
    external: true&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Step 2 is what produces the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;&amp;lt;tunnel-id&amp;gt;&lt;/code&gt; and the credentials JSON referenced below, without it, the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt; points at a file that doesn&amp;#x27;t exist. The daemon must actually be running, however you run it; this guide assumes the &lt;strong class=&quot;font-semibold text-white&quot;&gt;container&lt;/strong&gt; path.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A generic tunnel ingress config (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt;):&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;tunnel: &amp;lt;tunnel-id&amp;gt;
credentials-file: /etc/cloudflared/&amp;lt;tunnel-id&amp;gt;.json   # the file `tunnel create` wrote

ingress:
  # DEFAULT, cloudflared and Traefik are containers on the same Docker network,
  # so the name &amp;quot;traefik&amp;quot; resolves. Cloudflare terminates the browser-facing TLS
  # at its edge, so the tunnel&amp;#x27;s hop to Traefik&amp;#x27;s plain :80 entrypoint is fine.
  - hostname: app.example.com
    service: http://traefik:80
  # Catch-all MUST be last (required whenever you define ingress rules).
  - service: http_status:404&lt;/code&gt;&lt;/pre&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The addressing fork, get this right or nothing routes.&lt;/strong&gt; The service URL depends entirely on &lt;em&gt;where &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; runs&lt;/em&gt;:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; as a Docker container&lt;/strong&gt; (this guide&amp;#x27;s default): use &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;service: http://traefik:80&lt;/code&gt;. The name &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik&lt;/code&gt; resolves &lt;em&gt;only&lt;/em&gt; because both containers share the Docker network.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; as a host systemd service&lt;/strong&gt; (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared service install&lt;/code&gt;): a host process &lt;strong class=&quot;font-semibold text-white&quot;&gt;cannot&lt;/strong&gt; resolve Docker service names. Use &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;service: http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;, and have Traefik publish a host port bound to loopback only, e.g. &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ports: [&amp;quot;127.0.0.1:8080:80&amp;quot;]&lt;/code&gt;. Loopback isn&amp;#x27;t public, so the &lt;em&gt;zero public inbound ports&lt;/em&gt; story holds.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Do not mix the two.&lt;/strong&gt; A host-installed &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; pointing at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;http://traefik:80&lt;/code&gt; is the single most common way this setup fails, that name resolves only &lt;em&gt;inside&lt;/em&gt; Docker. On the host, route to loopback or a real host IP, never a Docker service name.&lt;/p&gt;&lt;/blockquote&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/cloudflared-deployment-modes.webp&quot; alt=&quot;Cloudflared deployment fork showing Docker mode routing tunnel ingress to http://traefik:80 on a shared Docker network, and host systemd mode routing to http://127.0.0.1 on a loopback-bound Traefik port.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Where cloudflared runs determines whether the tunnel targets Docker DNS or loopback.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;em&gt;Two things people conflate here:&lt;/em&gt;&lt;/p&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;(a) Encrypting the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared → Traefik&lt;/code&gt; hop.&lt;/strong&gt; Point the ingress at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;https://traefik:443&lt;/code&gt; and define a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;websecure&lt;/code&gt; (:443) entrypoint (§5). If Traefik serves an internal/self-signed cert, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;originRequest.noTLSVerify: true&lt;/code&gt; makes that work, but it &lt;em&gt;disables&lt;/em&gt; origin-cert verification, so this is &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; &amp;quot;validated&amp;quot; origin TLS.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;(b) Cloudflare-validated origin TLS.&lt;/strong&gt; Use the &lt;strong class=&quot;font-semibold text-white&quot;&gt;published-:443-origin variant&lt;/strong&gt;: publish only 443, present a Cloudflare &lt;strong class=&quot;font-semibold text-white&quot;&gt;Origin Certificate&lt;/strong&gt; (or a publicly-trusted cert), and set Cloudflare SSL/TLS mode to &lt;strong class=&quot;font-semibold text-white&quot;&gt;Full (strict)&lt;/strong&gt;, the only mode that actually validates the origin cert.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Through a tunnel the browser always validates &lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare&amp;#x27;s edge cert&lt;/strong&gt;, never Traefik&amp;#x27;s, so &amp;quot;browser-validated origin TLS&amp;quot; only exists on the published-origin path. The default tunnel + edge-TLS path needs none of this.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;DNS wiring, and who creates the record.&lt;/strong&gt; For a CLI-created (locally-managed) tunnel, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared tunnel route dns&lt;/code&gt; (step 3 above) &lt;strong class=&quot;font-semibold text-white&quot;&gt;creates the proxied CNAME for you&lt;/strong&gt;, already orange-cloud, mapping &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;app.example.com&lt;/code&gt; to &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;&amp;lt;tunnel-id&amp;gt;.cfargotunnel.com&lt;/code&gt;. Don&amp;#x27;t &lt;em&gt;also&lt;/em&gt; hand-create it, a second record risks a duplicate or a wrong-&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tunnel-id&lt;/code&gt; conflict. Hand-create the proxied CNAME yourself &lt;strong class=&quot;font-semibold text-white&quot;&gt;only&lt;/strong&gt; for dashboard/remotely-managed tunnels or when you&amp;#x27;re routing manually. Note that &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cfargotunnel.com&lt;/code&gt; records are &lt;strong class=&quot;font-semibold text-white&quot;&gt;proxied-only by design&lt;/strong&gt;, Cloudflare won&amp;#x27;t let you grey-cloud them, so the orange-cloud &amp;quot;master switch&amp;quot; is a real operator choice on the &lt;em&gt;published-:443-origin&lt;/em&gt; path, not on the tunnel path.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The #1 gotcha, locally-managed vs remotely-managed tunnels.&lt;/strong&gt; Cloudflare now nudges most people toward &lt;strong class=&quot;font-semibold text-white&quot;&gt;remotely-managed&lt;/strong&gt; tunnels (configured in the dashboard); this guide uses the &lt;strong class=&quot;font-semibold text-white&quot;&gt;locally-managed&lt;/strong&gt; (CLI + &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt;) path because config-as-code is easier to version, review, and reason about. The catch: if you created the tunnel in the Cloudflare dashboard (or via the API), the &lt;strong class=&quot;font-semibold text-white&quot;&gt;remote config is authoritative&lt;/strong&gt; and your local &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt; is &lt;em&gt;ignored&lt;/em&gt;. Edit ingress in the dashboard/API in that case. And when you update a remotely-managed tunnel via API, you must re-send the &lt;strong class=&quot;font-semibold text-white&quot;&gt;entire&lt;/strong&gt; ingress list, it&amp;#x27;s a replace, not a merge. Everything else in this section assumes the locally-managed (CLI) path above.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Optional identity gate.&lt;/strong&gt; Put Cloudflare Access in front of a hostname for SSO/OTP login &lt;em&gt;at the edge&lt;/em&gt;, with zero app code. Carve a path-scoped policy with action &lt;strong class=&quot;font-semibold text-white&quot;&gt;Bypass&lt;/strong&gt; (e.g. &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/healthz&lt;/code&gt;) so uptime probes stay green without logging in.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Verify correctly:&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A redirect to the Access login page is the healthy gated state, not a failure: &lt;strong class=&quot;font-semibold text-white&quot;&gt;browser flows get a 302; API/service-token flows get a 401 or 403.&lt;/strong&gt; Any of those means &amp;quot;gated,&amp;quot; not &amp;quot;broken.&amp;quot;&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;To prove your origin actually routes, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;curl&lt;/code&gt; the &lt;em&gt;bypassed&lt;/em&gt; path through real public DNS and expect a real &lt;strong class=&quot;font-semibold text-white&quot;&gt;200&lt;/strong&gt;, but this only holds if that path is covered by a policy whose action is &lt;strong class=&quot;font-semibold text-white&quot;&gt;Bypass&lt;/strong&gt; (evaluated first, no identity, no logging). An &lt;strong class=&quot;font-semibold text-white&quot;&gt;Allow&lt;/strong&gt; policy still challenges for identity, so it will 302 even on the &amp;quot;bypassed&amp;quot; path.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;An &lt;strong class=&quot;font-semibold text-white&quot;&gt;empty-body 404 with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;server: cloudflare&lt;/code&gt;&lt;/strong&gt; means the hostname has no ingress rule and never reached your origin.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;5-traefik-route-every-public-app-through-one-internal-front-door&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;5. Traefik: route every public app through one internal front door&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The problem it solves.&lt;/strong&gt; You have one IP and one pair of ports (:80/:443) but want to serve dozens of apps on different subdomains, without exposing a port per app or restarting anything to add a route.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;How it fits.&lt;/strong&gt; One Traefik container listens on internal entrypoints and routes each request to the right backend container &lt;strong class=&quot;font-semibold text-white&quot;&gt;by Host (and optional path)&lt;/strong&gt;. In the &lt;strong class=&quot;font-semibold text-white&quot;&gt;default tunnel path&lt;/strong&gt;, browser-facing TLS terminates at Cloudflare&amp;#x27;s edge and Traefik routes plain HTTP inside the Docker network; in the &lt;strong class=&quot;font-semibold text-white&quot;&gt;published-origin or mesh-TLS variants&lt;/strong&gt;, Traefik also serves HTTPS on &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;websecure&lt;/code&gt;. Routes hot-reload.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The #1 mental model: static vs dynamic config.&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Static&lt;/strong&gt; (entrypoints, ports, providers, cert resolvers) is set at startup and needs a &lt;strong class=&quot;font-semibold text-white&quot;&gt;restart&lt;/strong&gt; to change.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Dynamic&lt;/strong&gt; (routers, services, middlewares, certs) is &lt;strong class=&quot;font-semibold text-white&quot;&gt;hot-reloaded&lt;/strong&gt;, no restart.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Put each setting in the right layer and you&amp;#x27;ll never fight the proxy.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/traefik-static-vs-dynamic-config.webp&quot; alt=&quot;Traefik static versus dynamic configuration: static config defines entrypoints, providers, cert resolvers, and logging, while dynamic config defines routers, services, middlewares, and TLS certificates that hot reload.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Static config boots Traefik; dynamic config routes traffic.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Static config (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.yml&lt;/code&gt;), entrypoints, providers, cert resolvers.&lt;/strong&gt; Install-time concerns: set once, restart to change. The default tunnel path dials the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;web&lt;/code&gt; (:80) entrypoint; the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;websecure&lt;/code&gt; (:443) entrypoint is here only for the published-origin TLS variant. Whichever entrypoint a route uses must exist, or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; gets connection-refused:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;entryPoints:
  web:
    address: &amp;quot;:80&amp;quot;
  websecure:
    address: &amp;quot;:443&amp;quot;

providers:
  docker:
    endpoint: &amp;quot;tcp://dockerproxy:2375&amp;quot;   # the read-only socket proxy (see safety rules)
    exposedByDefault: false              # nothing is exposed unless it opts in
    network: proxy
  file:
    filename: /etc/traefik/dynamic.yml
    watch: true

# NOTE: TLS certificates/stores are DYNAMIC config, not static, they live in
# the `tls:` block of dynamic.yml below. Static config is only entrypoints,
# providers, cert resolvers, logging, and the dashboard toggle. (The default
# tunnel path needs no origin cert at all: Cloudflare terminates TLS at its edge
# and the tunnel dials plain :80. The cert only matters for the :443 variant.)&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A generic &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;docker-compose.yml&lt;/code&gt; for an app declaring its own route via &lt;strong class=&quot;font-semibold text-white&quot;&gt;labels&lt;/strong&gt;:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;services:
  my-app:
    image: my-app:latest
    networks: [proxy]            # shared proxy network
    labels:
      - &amp;quot;traefik.enable=true&amp;quot;
      - &amp;quot;traefik.http.routers.my-app.rule=Host(`app.example.com`)&amp;quot;
      - &amp;quot;traefik.http.routers.my-app.entrypoints=web&amp;quot;   # default tunnel path (edge TLS); use websecure + tls=true only for the published-origin variant
      - &amp;quot;traefik.http.services.my-app.loadbalancer.server.port=8080&amp;quot;

networks:
  proxy:
    external: true&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Note &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=true&lt;/code&gt; is explicit, because the static config sets &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;exposedByDefault=false&lt;/code&gt;, &lt;strong class=&quot;font-semibold text-white&quot;&gt;nothing&lt;/strong&gt; is exposed unless it opts in. Admin containers carry &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=false&lt;/code&gt;.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The same routes, expressed in a watched &lt;strong class=&quot;font-semibold text-white&quot;&gt;dynamic file&lt;/strong&gt; (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;dynamic.yml&lt;/code&gt;), useful for backends Traefik doesn&amp;#x27;t manage (raw &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;IP:port&lt;/code&gt;, cross-host services):&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;http:
  routers:
    my-app:
      rule: &amp;quot;Host(`app.example.com`)&amp;quot;
      entrypoints: [&amp;quot;websecure&amp;quot;]
      service: my-app-svc
      priority: 1                  # catch-all: lowest priority
      tls: {}                      # NOT a no-op: &amp;quot;serve over TLS with the default cert&amp;quot;
    my-app-api:
      rule: &amp;quot;Host(`app.example.com`) &amp;amp;&amp;amp; PathPrefix(`/api`)&amp;quot;
      entrypoints: [&amp;quot;websecure&amp;quot;]
      service: my-api-svc
      middlewares: [&amp;quot;strip-api&amp;quot;]
      priority: 10                 # higher than the catch-all above
      tls: {}

  services:
    my-app-svc:
      loadBalancer:
        servers:
          - url: &amp;quot;http://my-app:8080&amp;quot;
    my-api-svc:
      loadBalancer:
        servers:
          - url: &amp;quot;http://my-api:9000&amp;quot;

  middlewares:
    strip-api:
      stripPrefix:
        prefixes: [&amp;quot;/api&amp;quot;]         # backend sees a clean path, a frequent 404 fix
    mesh-only:
      ipAllowList:
        sourceRange:
          - &amp;quot;127.0.0.1/32&amp;quot;
          - &amp;quot;100.64.0.0/10&amp;quot;        # Tailscale CGNAT mesh range, NOT 10.x/192.168.x

tls:                               # top-level key, TLS certs are DYNAMIC config
  stores:
    default:
      defaultCertificate:          # served on :443 by default, published-origin variant only
        certFile: /run/secrets/origin.crt   # e.g. a Cloudflare Origin Certificate
        keyFile: /run/secrets/origin.key&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;A few things that bite beginners:&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Empty &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tls: {}&lt;/code&gt; is not a no-op&lt;/strong&gt;, it means &amp;quot;serve this over TLS with the default cert.&amp;quot; Omit it and the router answers plain HTTP. And note &lt;em&gt;which&lt;/em&gt; cert &amp;quot;default&amp;quot; is: if you haven&amp;#x27;t configured a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;defaultCertificate&lt;/code&gt; or an ACME resolver, it&amp;#x27;s a generated &lt;strong class=&quot;font-semibold text-white&quot;&gt;self-signed&lt;/strong&gt; cert, fine behind Cloudflare with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;noTLSVerify&lt;/code&gt;, but a browser hitting the origin directly will warn.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Priority matters&lt;/strong&gt; the moment a host has both a catch-all and a specific path. Give the catch-all &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;priority: 1&lt;/code&gt; and the specific path route a higher number, or the catch-all swallows &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/api&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;stripPrefix&lt;/code&gt;&lt;/strong&gt; drops &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/api&lt;/code&gt; before proxying, so the backend sees a clean path. Forgetting it is a classic source of 404s.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt;&lt;/strong&gt; restricts an admin route to loopback + the &lt;strong class=&quot;font-semibold text-white&quot;&gt;Tailscale mesh range &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;&lt;/strong&gt;, but only when the request &lt;em&gt;actually arrives over the mesh&lt;/em&gt; (see the caveat below). Do &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; put &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;10.0.0.0/8&lt;/code&gt; or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;192.168.0.0/16&lt;/code&gt; here: those are private-LAN ranges, not your tailnet, and allowlisting them opens the route to your whole LAN instead of locking it to the mesh.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Critical caveat, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; matches the IP Traefik actually sees.&lt;/strong&gt; Behind a Cloudflare Tunnel, the request reaches Traefik from the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared&lt;/code&gt; connector (or the Docker bridge), &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; your laptop&amp;#x27;s &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt; tailnet IP. So &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList: 100.64.0.0/10&lt;/code&gt; does &lt;strong class=&quot;font-semibold text-white&quot;&gt;nothing&lt;/strong&gt; on a public, Cloudflare-fronted hostname. It only gates a route reached &lt;strong class=&quot;font-semibold text-white&quot;&gt;directly over Tailscale&lt;/strong&gt; (a tailnet hostname / &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tsnet&lt;/code&gt; entrypoint, §6). Never put a mesh allowlist on a public route and assume it keeps admins-only.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The corollary: &lt;strong class=&quot;font-semibold text-white&quot;&gt;the right control depends on how the route is reached.&lt;/strong&gt;&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Route type&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;The control that actually works&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Public hostname via Cloudflare&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Cloudflare Access (SSO), app auth, service tokens, WAF rules&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Mesh-only hostname / listener&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Tailscale ACLs + bind policy + (optionally) Traefik &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Local-only admin tool&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Bind &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;127.0.0.1&lt;/code&gt;; reach via SSH or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;TLS, by deployment mode&lt;/strong&gt; (not all at once, the default needs none of the below):&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Default tunnel path:&lt;/strong&gt; Cloudflare terminates TLS at its edge; Traefik can stay plain HTTP internally.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Published-origin path:&lt;/strong&gt; a Cloudflare &lt;strong class=&quot;font-semibold text-white&quot;&gt;Origin Certificate&lt;/strong&gt; (or public-CA cert) + SSL/TLS mode &lt;strong class=&quot;font-semibold text-white&quot;&gt;Full (strict)&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Private mesh HTTPS:&lt;/strong&gt; &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt; or Tailscale certs.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;ACME / Let&amp;#x27;s Encrypt:&lt;/strong&gt; advanced, prefer &lt;strong class=&quot;font-semibold text-white&quot;&gt;DNS-01&lt;/strong&gt; when the origin shouldn&amp;#x27;t expose public port 80 (HTTP-01 validates on :80).&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Two hard safety rules:&lt;/strong&gt;&lt;/p&gt;&lt;ol class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-decimal&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Never mount the raw Docker socket read-write.&lt;/strong&gt; Front it with a read-only socket proxy and point Traefik&amp;#x27;s Docker provider at it, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;endpoint: tcp://dockerproxy:2375&lt;/code&gt; on the shared &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;proxy&lt;/code&gt; network, &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/var/run/docker.sock&lt;/code&gt;. Expose only the endpoints the provider needs: label/event discovery requires &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;CONTAINERS=1&lt;/code&gt; and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;EVENTS=1&lt;/code&gt;, plus &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;NETWORKS&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;INFO&lt;/code&gt;, and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;VERSION&lt;/code&gt;; add &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;TASKS&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;SERVICES&lt;/code&gt; only if you run Swarm. Read access to the socket is still privilege, minimize it. The proxy itself is one small service:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;dockerproxy:
  image: tecnativa/docker-socket-proxy
  environment: { CONTAINERS: 1, EVENTS: 1, NETWORKS: 1, INFO: 1, VERSION: 1 }
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro   # read-only
  networks: [proxy]
  restart: unless-stopped&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Inject secrets at runtime.&lt;/strong&gt; A clean pattern: override the container entrypoint with a tiny script that materializes cert/key files from env vars (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;umask 077&lt;/code&gt;, atomic &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;mktemp&lt;/code&gt;+&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;mv&lt;/code&gt;, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;chmod 600&lt;/code&gt; the key), then &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;exec&lt;/code&gt;s the real binary. Keys live only in the running container, never in git or the image.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Maintenance hazard:&lt;/strong&gt; file-provider routes can outlive their backend (a &amp;quot;dead route&amp;quot;). Periodically reconcile your routers against live backends, orphans are silent until someone hits the path.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;6-tailscale-reach-admin-tools-without-public-listeners&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;6. Tailscale: reach admin tools without public listeners&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The problem it solves.&lt;/strong&gt; You need to reach SSH, dashboards, metrics, and databases, but exposing any of them publicly is exactly what you&amp;#x27;re trying to avoid.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;How it fits.&lt;/strong&gt; Tailscale is a WireGuard mesh VPN (a &amp;quot;tailnet&amp;quot;). Every machine joins the mesh and gets a &lt;strong class=&quot;font-semibold text-white&quot;&gt;stable private IP, from the CGNAT range &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;, reachable only by other devices on the mesh&lt;/strong&gt;, never from the public internet. SSH rides the mesh too: no public port 22, no bastion, no jump box.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;*&lt;em&gt;Bind-address cheat-sheet, &lt;/em&gt;where&lt;em&gt; a service listens decides &lt;/em&gt;who&lt;em&gt; can reach it.&lt;/em&gt;* This is the most concrete version of the two-plane rule:&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/bind-address-exposure-matrix.webp&quot; alt=&quot;Bind-address exposure matrix showing 127.0.0.1 as local-only, 100.64.x.x as Tailscale mesh-only, 0.0.0.0 as every interface, 10.x and 192.168.x as LAN ranges, and public IPs as internet-facing.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;The bind address is the boundary: loopback, mesh, LAN, or public internet.&lt;/figcaption&gt;&lt;/figure&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Bind address&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Who can reach it&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Use for&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Same machine only&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Local-only admin; front with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt; or SSH&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.x.x&lt;/code&gt; (tailnet IP)&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Tailscale peers only&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Mesh admin: dashboards, metrics, databases&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0.0.0.0&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Every&lt;/strong&gt; interface, including public&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Avoid for admin, the classic accidental exposure&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;10.x&lt;/code&gt; / &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;192.168.x&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;LAN clients&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Not&lt;/strong&gt; the mesh, a different network entirely&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Public IP&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;The whole internet&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Only when deliberately publishing (prefer the tunnel)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Bring the box onto the mesh:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# Enroll the node (one login)
tailscale up&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Two different ways to reach a service over the mesh, pick one per service, don&amp;#x27;t stack them:&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt; (HTTPS front via your tailnet DNS name).&lt;/strong&gt; This proxies a local port through &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscaled&lt;/code&gt; and presents a real &lt;strong class=&quot;font-semibold text-white&quot;&gt;mesh TLS cert&lt;/strong&gt; on the node&amp;#x27;s MagicDNS name:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;tailscale serve --bg 8080&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Caveat: &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt; requires &lt;strong class=&quot;font-semibold text-white&quot;&gt;HTTPS certificates enabled in the tailnet&lt;/strong&gt; (MagicDNS + HTTPS certs). On first use it opens an &lt;strong class=&quot;font-semibold text-white&quot;&gt;interactive web-UI consent flow&lt;/strong&gt; to enable HTTPS, so it is &lt;em&gt;not&lt;/em&gt; headless-friendly unless that&amp;#x27;s already been enabled by a tailnet admin. This is the only one of the two patterns that gives you a TLS cert.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Direct mesh-IP port binding (raw port, no TLS).&lt;/strong&gt; Bind the container&amp;#x27;s port to the node&amp;#x27;s Tailscale IP so it&amp;#x27;s reachable on the mesh but nowhere else. This exposes the raw port directly, there is no mesh cert here.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The binding pattern, and its sharp edge.&lt;/strong&gt; In Docker Compose you can bind an internal service to the mesh IP, &lt;strong class=&quot;font-semibold text-white&quot;&gt;never&lt;/strong&gt; &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0.0.0.0&lt;/code&gt;:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;services:
  metrics:
    image: my-metrics:latest
    ports:
      # mesh IP, must already exist on an interface at container start
      - &amp;quot;${MESH_IP:-127.0.0.1}:9090:9090&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Important, this is not a graceful fallback.&lt;/strong&gt; A Docker &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ports:&lt;/code&gt; binding to a specific host IP requires that IP to &lt;em&gt;exist on an interface when the container starts&lt;/em&gt;. The &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;:-127.0.0.1&lt;/code&gt; default only fires when &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;MESH_IP&lt;/code&gt; is &lt;strong class=&quot;font-semibold text-white&quot;&gt;unset or empty&lt;/strong&gt;, not when it&amp;#x27;s set to a stale or currently-absent Tailscale IP. If &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;MESH_IP&lt;/code&gt; is hardcoded in an env file and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscaled&lt;/code&gt; is down or the address has changed, Docker fails the container with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cannot assign requested address&lt;/code&gt; rather than quietly serving on loopback. Two robust options: &lt;strong class=&quot;font-semibold text-white&quot;&gt;(a)&lt;/strong&gt; bind to loopback only and front the service with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt;, which survives IP churn via the tailnet name; or &lt;strong class=&quot;font-semibold text-white&quot;&gt;(b)&lt;/strong&gt; if you must bind the mesh IP, ensure it exists before container start and accept that a stale/missing IP is a hard failure. Prefer referencing the node by its &lt;strong class=&quot;font-semibold text-white&quot;&gt;MagicDNS name&lt;/strong&gt; over pinning a raw IP in env, consistent with the stable-names rule below.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Make the rule a test, not a tribal convention.&lt;/strong&gt; Parse &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ss -ltnp&lt;/code&gt; and fail CI if any policy-restricted port isn&amp;#x27;t bound inside &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;{loopback, Tailscale mesh range}&lt;/code&gt;. Use a real CIDR membership check, a substring grep is too blunt and easy to invert:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# Fail if an admin port is NOT bound to loopback or the Tailscale mesh.
# Allow-list by CIDR membership, never a substring deny-list:
#   loopback        127.0.0.0/8
#   Tailscale mesh  100.64.0.0/10   (100.64.0.0-100.127.255.255)
# NOTE: 10.x / 192.168.x are private-LAN ranges, NOT the mesh, do not allow them.
in_cidr() {  # in_cidr &amp;lt;ip&amp;gt; &amp;lt;network&amp;gt; &amp;lt;prefix&amp;gt;
  python3 - &amp;quot;$@&amp;quot; &amp;lt;&amp;lt;&amp;#x27;PY&amp;#x27;
import ipaddress,sys
ip,net,pfx=sys.argv[1],sys.argv[2],int(sys.argv[3])
sys.exit(0 if ipaddress.ip_address(ip) in ipaddress.ip_network(f&amp;quot;{net}/{pfx}&amp;quot;) else 1)
PY
}

bind_ip=$(ss -ltnp | awk &amp;#x27;/:9090 /{print $4}&amp;#x27; | sed &amp;#x27;s/:[0-9]*$//&amp;#x27; | head -n1)
if [ -z &amp;quot;$bind_ip&amp;quot; ]; then
  echo &amp;quot;OK: metrics port not listening&amp;quot;; exit 0
fi
if in_cidr &amp;quot;$bind_ip&amp;quot; 127.0.0.0 8 || in_cidr &amp;quot;$bind_ip&amp;quot; 100.64.0.0 10; then
  echo &amp;quot;OK: metrics is mesh/loopback only ($bind_ip)&amp;quot;
else
  echo &amp;quot;FAIL: metrics port publicly/LAN bound ($bind_ip)&amp;quot;; exit 1
fi&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Two complementary controls&lt;/strong&gt;, use both for sensitive services:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Tailnet ACLs&lt;/strong&gt; decide &lt;em&gt;which peers&lt;/em&gt; may reach which ports.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Bind-policy&lt;/strong&gt; decides &lt;em&gt;which interface&lt;/em&gt; a service listens on.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Stable names:&lt;/strong&gt; use the mesh DNS (MagicDNS) names in config and scripts so they survive IP churn. For cross-host automation, prefer the &lt;strong class=&quot;font-semibold text-white&quot;&gt;fully-qualified&lt;/strong&gt; name, bare short names don&amp;#x27;t always resolve.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;*&lt;em&gt;When &lt;/em&gt;not&lt;em&gt; to use the mesh:&lt;/em&gt;&lt;em&gt; a flow that needs a &lt;/em&gt;&lt;em&gt;public callback&lt;/em&gt;&lt;em&gt; (OAuth, webhooks) can&amp;#x27;t run on a raw-IP HTTP mesh endpoint, providers reject &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;http://&lt;/code&gt; and raw IPs. Those surfaces belong on the public plane with a real domain and TLS. The mesh is for &lt;/em&gt;&lt;em&gt;humans-as-admins and server-to-server&lt;/em&gt;*.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The verification reflex:&lt;/strong&gt; a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;200&lt;/code&gt; on &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;localhost&lt;/code&gt; tells you &lt;em&gt;nothing&lt;/em&gt; about public exposure. Check the actual bind address (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ss -ltnp&lt;/code&gt;), not just that the service responds.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;7-putting-it-together-deploy-a-sample-app-end-to-end&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;7. Putting it together: deploy a sample app end to end&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Let&amp;#x27;s ship a real, copy-paste-runnable container, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik/whoami&lt;/code&gt;, which just echoes the request it received, so you can &lt;em&gt;see&lt;/em&gt; the chain working, at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;app.example.com&lt;/code&gt;.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;1. Run it on the shared proxy network.&lt;/strong&gt; The proxy reaches it by &lt;em&gt;service name&lt;/em&gt;, not localhost (from inside the proxy, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;localhost&lt;/code&gt; is the &lt;em&gt;proxy&lt;/em&gt; itself):&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;docker network create proxy      # once, if it doesn&amp;#x27;t exist
docker run -d --name whoami --network proxy --label traefik.enable=false traefik/whoami&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;2. Give it a Traefik router.&lt;/strong&gt; Drop &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=false&lt;/code&gt; and add the router labels, here as a compose file you can keep. Note &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;entrypoints=web&lt;/code&gt; (:80): the tunnel terminates browser TLS at Cloudflare&amp;#x27;s edge, so the app is plain HTTP &lt;em&gt;inside&lt;/em&gt; the box.&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;# whoami.compose.yml
services:
  whoami:
    image: traefik/whoami
    networks: [proxy]
    labels:
      - &amp;quot;traefik.enable=true&amp;quot;
      - &amp;quot;traefik.http.routers.whoami.rule=Host(`app.example.com`)&amp;quot;
      - &amp;quot;traefik.http.routers.whoami.entrypoints=web&amp;quot;
      - &amp;quot;traefik.http.services.whoami.loadbalancer.server.port=80&amp;quot;
networks:
  proxy:
    external: true&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;docker rm -f whoami                          # remove the enable=false test container
docker compose -f whoami.compose.yml up -d   # bring it up with its router labels&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;3. Expose it publicly.&lt;/strong&gt; If you&amp;#x27;re using a tunnel, the proxied CNAME was created by &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared tunnel route dns&lt;/code&gt; in §4 (don&amp;#x27;t hand-make a second one); add the matching ingress rule to &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;config.yml&lt;/code&gt;. For the published-:443-origin path instead, create the proxied DNS record yourself and set SSL mode to Full (strict). No new public port, ever.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;4. Add a second route to show fan-out.&lt;/strong&gt; A &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;/api&lt;/code&gt; &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;PathPrefix&lt;/code&gt; router at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;priority: 10&lt;/code&gt; to a backend, with the UI catch-all at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;priority: 1&lt;/code&gt;.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;5. Verify the FULL chain&lt;/strong&gt;, not just localhost:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# Real DNS, no --resolve pin: this exercises Cloudflare → tunnel → proxy → app
curl -I https://app.example.com   # expect 200
curl -I http://app.example.com    # 301/308 IF you enabled HTTP→HTTPS redirect
                                  # (Cloudflare or Traefik); otherwise 200&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Recommended:&lt;/strong&gt; turn on &lt;strong class=&quot;font-semibold text-white&quot;&gt;HTTP → HTTPS redirect&lt;/strong&gt; for public hostnames, Cloudflare&amp;#x27;s &lt;em&gt;Always Use HTTPS&lt;/em&gt;, or an explicit Traefik redirect on the published-origin path. Until you do, a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;200&lt;/code&gt; on plain &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;http://&lt;/code&gt; is expected, not a failure.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Note what the cert actually is:&lt;/strong&gt; via the tunnel the browser sees &lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare&amp;#x27;s edge cert&lt;/strong&gt;, so a green padlock here does &lt;em&gt;not&lt;/em&gt; prove your origin cert is real, it could be Traefik&amp;#x27;s self-signed default and you&amp;#x27;d never know. To validate the &lt;em&gt;origin&lt;/em&gt; cert end to end you need the Full (strict) published-origin path, which actually checks it.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;6. Now add an admin surface and keep it OFF the public plane.&lt;/strong&gt; Bind the metrics dashboard to the mesh IP (or front it with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt;), set &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=false&lt;/code&gt; (or give it a mesh-only &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; router scoped to &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;), and reach it &lt;strong class=&quot;font-semibold text-white&quot;&gt;only over Tailscale.&lt;/strong&gt; The allowlist works &lt;em&gt;here&lt;/em&gt;, and not on a public route, precisely because the request arrives directly over the mesh, so Traefik sees your tailnet IP (§5 caveat).&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Deploy hygiene:&lt;/strong&gt; recreating containers is what picks up changes. Flipping a release symlink alone does &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; restart them, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;docker compose up -d --force-recreate&lt;/code&gt;, and verify by &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Created&lt;/code&gt; time / image / mount, &lt;strong class=&quot;font-semibold text-white&quot;&gt;not&lt;/strong&gt; just &amp;quot;healthy.&amp;quot; Inject secrets at runtime so they never land on disk.&lt;/p&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;8-authentication-which-lock-for-which-door&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;8. Authentication: which lock for which door?&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&amp;quot;Self-hosted&amp;quot; doesn&amp;#x27;t mean &amp;quot;open.&amp;quot; But the mistake beginners make isn&amp;#x27;t &lt;em&gt;forgetting&lt;/em&gt; auth, it&amp;#x27;s reaching for &lt;strong class=&quot;font-semibold text-white&quot;&gt;one&lt;/strong&gt; kind of auth and bolting it onto everything. A password prompt in front of an API that a script calls is useless friction; an OAuth login in front of a tool only &lt;em&gt;you&lt;/em&gt; touch over the mesh is wasted effort. The skill is matching the lock to the door.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Two questions decide it every time:&lt;/p&gt;&lt;ol class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-decimal&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Who&amp;#x27;s knocking&lt;/strong&gt;, a human in a browser, a machine/script, or just you-the-operator?&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;What&amp;#x27;s behind the door&lt;/strong&gt;, a public app, an internal dashboard, or a programmatic API?&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You&amp;#x27;ll usually run &lt;strong class=&quot;font-semibold text-white&quot;&gt;several of these at once&lt;/strong&gt;, that&amp;#x27;s correct, not over-engineering. The goal isn&amp;#x27;t to pick one lock; it&amp;#x27;s to put each one on the right door.&lt;/p&gt;&lt;figure class=&quot;my-8 lg:-mx-20 lg:w-[calc(100%+10rem)]&quot;&gt;&lt;img src=&quot;https://www.danmercede.com/assets/guides/self-hosting/authentication-lock-comparison.webp&quot; alt=&quot;Authentication comparison for self-hosted apps showing Tailscale ACLs and bind policy for operator-only tools, Cloudflare Access for human browser login, Basic Auth for small public tools, service tokens for machine callers, and app-layer login for user accounts.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; class=&quot;h-auto w-full rounded-lg border border-white/10 bg-slate-900/40&quot;/&gt;&lt;figcaption class=&quot;mt-3 text-center text-sm text-slate-400&quot;&gt;Match the lock to the caller: human, machine, operator, or app user.&lt;/figcaption&gt;&lt;/figure&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;1. The cheapest lock is the network itself.&lt;/strong&gt; For anything only &lt;em&gt;you&lt;/em&gt; administer, the mesh is the first gate: keep it off the public plane and scope a Traefik &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; to &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt; (§6). A service with no public listener can&amp;#x27;t be brute-forced, there&amp;#x27;s no door to pick. For a &lt;strong class=&quot;font-semibold text-white&quot;&gt;low-risk personal&lt;/strong&gt; dashboard, Tailscale ACLs + bind-policy are often enough on their own. But network reach is &lt;em&gt;access&lt;/em&gt;, not &lt;em&gt;authorization&lt;/em&gt;: for anything &lt;strong class=&quot;font-semibold text-white&quot;&gt;destructive, sensitive, or multi-user&lt;/strong&gt;, keep the app&amp;#x27;s own auth enabled too, and still change its default credentials. The goal isn&amp;#x27;t one lock; it&amp;#x27;s matching the lock to the blast radius.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;2. Basic Auth, the one-line password wall.&lt;/strong&gt; When a small, low-stakes tool &lt;em&gt;is&lt;/em&gt; public and you just want a gate, Traefik&amp;#x27;s &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;basicAuth&lt;/code&gt; middleware is the least-effort option. Generate a bcrypt credential and attach the middleware:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-bash&quot;&gt;# htpasswd ships with apache2-utils; -B = bcrypt, -n = print (don&amp;#x27;t write a file)
htpasswd -nbB admin &amp;#x27;choose-a-strong-unique-password&amp;#x27;
# → admin:$2y$05$REPLACE_WITH_YOUR_OWN_BCRYPT_HASH&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;# As a Docker label. NOTE: in docker-compose, every literal $ in the hash
# must be DOUBLED to $$ or Compose will try to interpolate it as a variable.
labels:
  - &amp;quot;traefik.http.routers.my-tool.middlewares=tool-auth&amp;quot;
  - &amp;quot;traefik.http.middlewares.tool-auth.basicauth.users=admin:$$2y$$05$$REPLACE_WITH_YOUR_OWN_BCRYPT_HASH&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;# Or in the dynamic file (single $ here, no doubling outside compose):
http:
  middlewares:
    tool-auth:
      basicAuth:
        users:
          - &amp;quot;admin:$2y$05$REPLACE_WITH_YOUR_OWN_BCRYPT_HASH&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Know its limits before you trust it: Basic Auth sends a base64 credential on &lt;strong class=&quot;font-semibold text-white&quot;&gt;every&lt;/strong&gt; request, so it is &lt;strong class=&quot;font-semibold text-white&quot;&gt;only safe when every hop carrying it is protected&lt;/strong&gt;. On this guide&amp;#x27;s default tunnel path, the browser-facing hop is HTTPS (Cloudflare&amp;#x27;s edge) and the final &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared → Traefik&lt;/code&gt; hop is plain HTTP but stays private inside the box&amp;#x27;s Docker network, never the public wire; if you instead publish the origin directly, require HTTPS end to end (the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;websecure&lt;/code&gt; / Full-strict variant). It has no logout, no sessions, and usually one shared credential, so &lt;strong class=&quot;font-semibold text-white&quot;&gt;rotate it&lt;/strong&gt; and don&amp;#x27;t put it alone in front of anything sensitive. It&amp;#x27;s a tidy gate for a staging tool or a webhook receiver, not an identity system.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;3. Identity at the edge, SSO without writing login code.&lt;/strong&gt; When real humans should authenticate with an identity (Google, email OTP, a company SSO), put an &lt;strong class=&quot;font-semibold text-white&quot;&gt;identity proxy in front of the route&lt;/strong&gt; so an unauthenticated request never reaches your app. Two flavors:&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare Access&lt;/strong&gt; (Zero Trust, free tier): define an application for &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;app.example.com&lt;/code&gt;, attach an allow policy (e.g. &amp;quot;these emails, email-OTP, 24h session&amp;quot;), and Cloudflare challenges the request at its edge before the tunnel. Browser flows get a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;302&lt;/code&gt; to &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;&amp;lt;team&amp;gt;.cloudflareaccess.com&lt;/code&gt;; API/service-token flows get &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;401&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;403&lt;/code&gt;. All of those mean &lt;em&gt;gated&lt;/em&gt;, not broken.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Self-hosted forward-auth&lt;/strong&gt; (Authelia, Authentik, or oauth2-proxy) when you&amp;#x27;d rather not depend on Cloudflare for identity. Traefik calls your auth service on every request via a &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;forwardAuth&lt;/code&gt; middleware:&lt;/p&gt;&lt;pre class=&quot;my-5 overflow-x-auto rounded-lg border border-white/10 bg-slate-950/80 p-4 text-sm leading-relaxed&quot;&gt;&lt;code class=&quot;font-mono text-slate-300 language-yaml&quot;&gt;http:
  middlewares:
    sso:
      forwardAuth:
        address: &amp;quot;http://auth:9091/api/verify?rd=https://auth.example.com/&amp;quot;
        trustForwardHeader: true
        authResponseHeaders: [&amp;quot;Remote-User&amp;quot;, &amp;quot;Remote-Groups&amp;quot;]&lt;/code&gt;&lt;/pre&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Point any route at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;middlewares: [&amp;quot;sso&amp;quot;]&lt;/code&gt;; the auth service owns the login UI and returns &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;2xx&lt;/code&gt; (allow) or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;401&lt;/code&gt; (challenge). Your app stays login-free.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;4. Tokens for machines.&lt;/strong&gt; Browsers can do SSO; scripts, CI jobs, and webhook senders can&amp;#x27;t. Give them &lt;strong class=&quot;font-semibold text-white&quot;&gt;bearer or service tokens&lt;/strong&gt;, &lt;strong class=&quot;font-semibold text-white&quot;&gt;deny by default&lt;/strong&gt;, one token per caller, scoped and rotatable. Cloudflare Access issues &lt;strong class=&quot;font-semibold text-white&quot;&gt;service tokens&lt;/strong&gt; for exactly this, a machine path behind the same edge gate, authenticated by a token header instead of a login. At the app layer, a tiny middleware that rejects any request without a valid &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;Authorization: Bearer …&lt;/code&gt; is the same idea self-hosted. Whichever you use: never log the token, and rotate on a schedule.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;5. App-layer login, for real accounts.&lt;/strong&gt; If the app has its own users, roles, and sessions, that logic belongs &lt;strong class=&quot;font-semibold text-white&quot;&gt;in the app&lt;/strong&gt; (sessions, JWT, or OIDC). Keep it even when an edge gate already fronts the route: the two are &lt;strong class=&quot;font-semibold text-white&quot;&gt;defense in depth&lt;/strong&gt;, and an edge policy is one misconfiguration away from &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;bypass&lt;/code&gt;. A fail-closed app-level floor means a slipped edge rule degrades to &amp;quot;logged warning,&amp;quot; not &amp;quot;open door.&amp;quot;&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;The rules that keep auth sane:&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Stack gates on sensitive surfaces:&lt;/strong&gt; edge identity → proxy &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; → app auth. One wall is a single point of failure; layers survive a mistake in any one of them.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Deny by default.&lt;/strong&gt; Never &amp;quot;secure by obscurity&amp;quot;, an unguessable URL is not auth.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Credentials only over HTTPS.&lt;/strong&gt; Basic Auth, bearer tokens, cookies, all of it.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Don&amp;#x27;t over- or under-engineer.&lt;/strong&gt; No OAuth on a tool only you reach over the mesh; no bare Basic Auth in front of real user data.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;*&lt;em&gt;Bypass &lt;/em&gt;narrowly&lt;em&gt;, and know what it costs.&lt;/em&gt;&lt;em&gt; Bypass turns &lt;/em&gt;&lt;em&gt;off&lt;/em&gt;&lt;em&gt; Cloudflare Access enforcement &lt;/em&gt;&lt;em&gt;and&lt;/em&gt;&lt;em&gt; request logging for matching traffic. &lt;/em&gt;&lt;em&gt;Health checks&lt;/em&gt;&lt;em&gt; are usually fine to Bypass. &lt;/em&gt;&lt;em&gt;Metrics should stay private&lt;/em&gt;&lt;em&gt; (they leak internals), keep them on the mesh, don&amp;#x27;t Bypass them onto the public plane. &lt;/em&gt;&lt;em&gt;Webhooks/automation&lt;/em&gt;&lt;em&gt; that still need auth + an audit trail belong on a &lt;/em&gt;&lt;em&gt;service token (Service Auth)&lt;/em&gt;* or an app-layer signature check, never a blanket Bypass.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;9-security-checklist&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;9. Security checklist&lt;/h2&gt;&lt;blockquote class=&quot;my-5 border-l-2 border-copper-500/50 pl-5 text-slate-300&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Before you check a single box, re-ask the one question:&lt;/strong&gt; &lt;em&gt;Does the public need to reach this?&lt;/em&gt; If the answer is no, it should have no public listener at all.&lt;/p&gt;&lt;/blockquote&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The single most important lesson: &lt;strong class=&quot;font-semibold text-white&quot;&gt;never expose admin dashboards publicly, keep them on the Tailscale plane.&lt;/strong&gt; Everything else is defense in depth.&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Every public DNS record that fronts an app is PROXIED (orange-cloud).&lt;/strong&gt; No grey-cloud records leaking your origin IP. (Tunnel &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cfargotunnel.com&lt;/code&gt; CNAMEs are proxied-only by design; the orange-cloud choice is yours to get wrong only on the published-origin path.)&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;If using origin certs with a published port: SSL mode is Full (strict)&lt;/strong&gt;, the only mode that &lt;em&gt;validates&lt;/em&gt; the origin cert. Not Flexible, not plain Full.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Inbound firewall is closed except what you truly need.&lt;/strong&gt; A tunnel needs &lt;strong class=&quot;font-semibold text-white&quot;&gt;zero&lt;/strong&gt; inbound; a published origin needs only &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;443&lt;/code&gt;, ideally locked to Cloudflare&amp;#x27;s IP ranges.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Every admin/internal service binds to loopback or the Tailscale mesh IP (&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt;)&lt;/strong&gt;, verified with &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ss -ltnp&lt;/code&gt;, not assumed. A CI bind-policy test enforces it with a real CIDR check, and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;10.x&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;192.168.x&lt;/code&gt; LAN ranges are &lt;em&gt;not&lt;/em&gt; treated as the mesh.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;exposedByDefault=false&lt;/code&gt;&lt;/strong&gt; on the proxy; only explicitly-labelled containers get a public router. Admin containers carry &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=false&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;The Docker socket is read-only via a socket proxy&lt;/strong&gt;, never mounted read-write into the proxy; the Traefik provider points at &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tcp://dockerproxy:2375&lt;/code&gt;, not the raw socket.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Secrets are never in git or the image&lt;/strong&gt;, injected at runtime; cert/key files written &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;0600&lt;/code&gt; by an entrypoint script at container start.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Change default credentials on every dashboard&lt;/strong&gt; before it&amp;#x27;s reachable by &lt;em&gt;anyone&lt;/em&gt;, including over the mesh.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] *&lt;em&gt;Public sensitive routes stack &lt;/em&gt;public-plane&lt;em&gt; gates:&lt;/em&gt;&lt;em&gt; Cloudflare Access / Service Auth → app-layer auth or token validation. &lt;/em&gt;&lt;em&gt;Do not&lt;/em&gt;* rely on a Tailscale &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; on a Cloudflare-fronted public hostname (Traefik sees the tunnel connector, not your tailnet IP).&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] *&lt;em&gt;Mesh-only sensitive routes stack &lt;/em&gt;private-plane&lt;em&gt; gates:&lt;/em&gt;* Tailscale ACLs → bind policy → optional Traefik &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ipAllowList&lt;/code&gt; → app auth, for destructive/sensitive/multi-user tools.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Treat tunnel ingress as config-as-code and reconcile it.&lt;/strong&gt; A periodic diff of live config vs your declared routes catches an unexpectedly-exposed hostname &lt;em&gt;before&lt;/em&gt; it&amp;#x27;s an incident.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Reconcile orphaned routes.&lt;/strong&gt; A router pointing at a dead backend is a silent maintenance hazard.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;[ ] &lt;strong class=&quot;font-semibold text-white&quot;&gt;Tear down a sensitive route in the right order:&lt;/strong&gt; remove the public route (DNS + tunnel rule) and confirm the data plane no longer reaches origin &lt;strong class=&quot;font-semibold text-white&quot;&gt;before&lt;/strong&gt; deleting the identity gate. An ungated live route is a breach; a stale gate on a dead route is harmless.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;hr class=&quot;my-10 border-white/10&quot;/&gt;&lt;h2 id=&quot;10-advanced-hardening-once-the-base-path-works&quot; class=&quot;mt-12 mb-4 scroll-mt-24 text-2xl md:text-3xl font-bold text-white&quot;&gt;10. Advanced hardening once the base path works&lt;/h2&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;You now have a defensible foundation. Once the happy path is solid, harden it in four directions:&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Observe&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Structured JSON access logs from the proxy with a &lt;strong class=&quot;font-semibold text-white&quot;&gt;field allowlist&lt;/strong&gt; (drop headers by default), shipped to a log store you reach &lt;em&gt;only&lt;/em&gt; over the mesh.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Deploy&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A &lt;strong class=&quot;font-semibold text-white&quot;&gt;materialize → ship → flip&lt;/strong&gt; release pattern with runtime secret injection, so deploys are atomic and rollback-able. Remember: flipping a symlink doesn&amp;#x27;t restart containers, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;docker compose up -d --force-recreate&lt;/code&gt; does.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Scale&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A &lt;strong class=&quot;font-semibold text-white&quot;&gt;second box&lt;/strong&gt;, route cross-host backends through the proxy&amp;#x27;s &lt;strong class=&quot;font-semibold text-white&quot;&gt;file provider&lt;/strong&gt;: one front door, many machines.&lt;/p&gt;&lt;/li&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;More admin surfaces&lt;/strong&gt;, layer Cloudflare Access policies as you add them, and keep the &lt;strong class=&quot;font-semibold text-white&quot;&gt;mesh as the floor&lt;/strong&gt; for anything that doesn&amp;#x27;t &lt;em&gt;need&lt;/em&gt; the public.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Audit&lt;/strong&gt;&lt;/p&gt;&lt;ul class=&quot;my-5 space-y-2 pl-6 text-slate-300 marker:text-copper-500/70 list-disc&quot;&gt;&lt;li class=&quot;leading-relaxed&quot;&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Reconcile &lt;strong class=&quot;font-semibold text-white&quot;&gt;orphaned routers&lt;/strong&gt; (routes pointing at dead backends) and &lt;strong class=&quot;font-semibold text-white&quot;&gt;tunnel-ingress drift&lt;/strong&gt; (a hostname exposed that you never declared), and enforce the &lt;strong class=&quot;font-semibold text-white&quot;&gt;bind-policy CI check&lt;/strong&gt; so the two-plane rule is &lt;em&gt;tested, not trusted&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Troubleshooting, the failure-mode tells worth knowing cold:&lt;/strong&gt;&lt;/p&gt;&lt;div class=&quot;my-6 overflow-x-auto&quot;&gt;&lt;table class=&quot;w-full border-collapse text-left text-sm&quot;&gt;&lt;thead&gt;&lt;tr class=&quot;border-b border-copper-500/30&quot;&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Symptom&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Likely cause&lt;/th&gt;&lt;th class=&quot;px-3 py-2 font-semibold text-white&quot;&gt;Check&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Redirect to login&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Gated and healthy&lt;/strong&gt;, not broken&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;302&lt;/code&gt; (browser) or &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;401&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;403&lt;/code&gt; (API/service-token) all mean &amp;quot;gated&amp;quot;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Edge &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;404&lt;/code&gt;, empty body, &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;server: cloudflare&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Missing tunnel &lt;strong class=&quot;font-semibold text-white&quot;&gt;ingress rule&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;the hostname never reached your origin&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;502&lt;/code&gt; at the edge&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Tunnel reached origin, origin &lt;strong class=&quot;font-semibold text-white&quot;&gt;refused&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Traefik entrypoint + the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;service:&lt;/code&gt; URL (the addressing fork, §4)&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Works on &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;localhost&lt;/code&gt; only&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Not routed through the &lt;strong class=&quot;font-semibold text-white&quot;&gt;public chain&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;curl&lt;/code&gt; real DNS, not loopback&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Admin app reachable publicly&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Wrong bind or a stray &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;traefik.enable=true&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;ss -ltnp&lt;/code&gt; + the container&amp;#x27;s labels&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-white/5&quot;&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;Green padlock but origin unverified&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;That&amp;#x27;s &lt;strong class=&quot;font-semibold text-white&quot;&gt;Cloudflare&amp;#x27;s edge cert&lt;/strong&gt;&lt;/td&gt;&lt;td class=&quot;px-3 py-2 align-top text-slate-300&quot;&gt;validate the &lt;em&gt;origin&lt;/em&gt; cert via the Full (strict) path&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;&lt;strong class=&quot;font-semibold text-white&quot;&gt;Further reading:&lt;/strong&gt; Traefik routers/middlewares and static-vs-dynamic config docs, Cloudflare Tunnel + Zero Trust docs (including &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;cloudflared tunnel create&lt;/code&gt;/&lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;route dns&lt;/code&gt;), Tailscale ACLs, the &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;100.64.0.0/10&lt;/code&gt; CGNAT addressing model, and &lt;code class=&quot;rounded bg-slate-800/70 px-1.5 py-0.5 font-mono text-[0.85em] text-copper-300&quot;&gt;tailscale serve&lt;/code&gt;, plus the Let&amp;#x27;s Encrypt/ACME challenge-type reference.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The discipline that makes all of this hold together is a single sentence: &lt;strong class=&quot;font-semibold text-white&quot;&gt;classify every service by whether the public needs it, put it on the matching plane, and verify the bind address, not just the health check.&lt;/strong&gt; Do that consistently and your self-hosted estate stays sane no matter how many apps you add.&lt;/p&gt;</content:encoded></item>
<item><title>Verify the Verifier</title><link>https://www.danmercede.com/thoughts/2026-06-24-verify-the-verifier-v2</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-24-verify-the-verifier-v2</guid><pubDate>Wed, 24 Jun 2026 07:00:00 +0000</pubDate><description>A verification agent is still a single source: the two-tier draft-then-skeptic pattern inverts the moment the skeptic introduces its own error, because the architecture has pre-committed to trusting the second voice.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A verification agent is still a single source. The two-tier pattern, generate cheap, then run an adversarial skeptic that checks each claim against live state, earns its keep when the skeptic catches the draft&amp;#x27;s errors. It inverts the moment the skeptic introduces its own. The asymmetry that makes the pattern work, &lt;em&gt;assume the draft is wrong&lt;/em&gt;, is the same one that makes a wrong correction dangerous: the architecture has pre-committed to trusting the second voice, and nothing downstream is positioned to doubt it. The second pass &lt;em&gt;was&lt;/em&gt; the doubt.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The tell is cheap to check and easy to skip. A confident &amp;quot;CONFIRMED, corrected&amp;quot; that flips a premise the draft had right is not a resolution; it is a flag. Read the skeptic&amp;#x27;s cited evidence: does it name the artifact under test, or a sibling that resembles it? Mismatched-but-confident is the signature of a backside error one layer up.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A correction earns its way into the output the way authority does, on proof, not standing trust. Promote it on a citation that names the right thing, on agreement across the fan-out, or on one direct probe you ran yourself. A verdict from an unverified verifier is theater.&lt;/p&gt;</content:encoded></item>
<item><title>The Gate Is the Product</title><link>https://www.danmercede.com/thoughts/2026-06-16-the-gate-is-the-product</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-16-the-gate-is-the-product</guid><pubDate>Tue, 16 Jun 2026 07:00:00 +0000</pubDate><description>Agent generation is not the constraint, a fleet produces more than a human can review. The merge-authority gate, plus the work of converging what was generated, is the constraint. So governance ships shadow-first, and authority is delivered on demand and never left at rest.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;When a fleet of agents produces eight sprints of work in two days, the instinct is to celebrate generation. But the abandoned drafts pile up by the hundreds, and the real scarcity reveals itself: a human authorizes and converges only so much. Generation was never the constraint. The merge-authority gate, and the labor of reconciling what was generated into something coherent, is.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That reframes what the product is. The gate is the product, the thing that decides what crosses from generated into shipped, and it earns trust by shipping shadow-first, observing what it would have done before it acts. Authority follows the same rule: delivered on demand at the moment of decision, never a bearer credential left sitting at rest. The model is replaceable. The gate is the part that lasts.&lt;/p&gt;</content:encoded></item>
<item><title>Open-Source the Gate, Not the Verdict</title><link>https://www.danmercede.com/thoughts/2026-06-09-open-source-the-gate-not-the-verdict</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-09-open-source-the-gate-not-the-verdict</guid><pubDate>Tue, 09 Jun 2026 07:00:00 +0000</pubDate><description>The model that reads a diff is replaceable; the deterministic gate around it is not. What you publish, and what does the governing, is the gate: default-deny, runnable, receipt-leaving, with the model behind a seam you can swap without touching it.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;It is tempting to think the intelligence is the product, that a sharper reviewer model is what makes AI code review safe. It is the opposite. The model that reads a diff is replaced within months. The thing that governs is the gate around it: the deterministic logic that default-denies on an unparseable or contradictory verdict and leaves a receipt of what it decided.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;So the gate is what you open-source and the gate is what does the governing. The model sits behind a command seam and is swapped without touching the enforcement around it. Publishing the verdict ties governance to a model version; publishing the gate gives you something that holds as the models churn underneath it. The durable artifact is the boundary, not the brain.&lt;/p&gt;</content:encoded></item>
<item><title>The Authority Gate, Made Runnable: Fail-Closed Merge Admission for Agent-Written Code</title><link>https://www.danmercede.com/thoughts/2026-06-08-authority-gate-made-runnable</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-08-authority-gate-made-runnable</guid><pubDate>Mon, 08 Jun 2026 07:00:00 +0000</pubDate><description>An advisory reviewer that relays an AI&#39;s own verdict fails open; fail-closed merge admission applies the pre-execution authority gate to the merge boundary by refusing MERGE_READY on unparseable, schema-invalid, or self-contradictory output.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part I, The Gate, Made Runnable&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Every argument for runtime governance eventually meets the same objection: show me. The pre-execution authority gate is a clean idea, evaluate authority before state mutation, halt on ambiguity, never fall back to probabilistic scoring, but an idea is not an artifact. failclosed is the artifact: the authority gate applied to one narrow, high-frequency mutation boundary, the merge.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The choice of boundary is deliberate. A merge is small, concrete, and constant, and it is where AI-written code crosses from proposal into production state. It is also where the current generation of tooling fails in a specific, instructive way.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part II, Why Advisory Review Fails Open&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The dominant pattern in AI code review relays the model&amp;#x27;s own verdict. A reviewer model reads a diff, decides whether it looks acceptable, and the surrounding tool treats that decision as the gate. When the model says &amp;quot;looks good,&amp;quot; the change is mergeable. This is advisory governance, and advisory governance fails open by construction.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Consider the failure that matters most: the reviewer&amp;#x27;s output is not clean. It is truncated by a token limit, malformed by a formatting slip, or internally contradictory, a verdict of &amp;quot;needs fixes&amp;quot; attached to a findings list where not one finding cites a file or a line. A human reading that output distrusts it immediately. An advisory tool does the opposite. With no clean verdict to relay, it defaults to permitting the merge, because permitting is the path of least resistance and no deterministic rule forces a stop. The reviewer becomes a single point of silent failure precisely when its output is least trustworthy.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The cost is not abstract. A merge is a state mutation. An ungoverned merge of agent-written code is an irreversible state change with no attestation of who, or what, authorized it. In a regulated environment, that is audit indefensibility, the inability to prove, after the fact, that a change was admitted under an enforced rule rather than a model&amp;#x27;s good mood.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part III, Distrust the Reviewer&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;failclosed treats the reviewer the way the control plane treats an agent: as an untrusted compute node whose output is inadmissible until proven. It runs the reviewer, and then it refuses to trust the verdict.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The output passes through a deterministic parser. The gate refuses to report MERGE_READY when that output is unparseable, schema-invalid, or self-contradictory. Take the contradictory case: verdict &amp;quot;fix,&amp;quot; no finding citing a file. An advisory tool, finding no actionable findings, reports the change clean and admits it. failclosed reads the same output, recognizes that a &amp;quot;fix&amp;quot; verdict with no located finding is an indeterminate state, and halts. Ambiguity translates to a stop. There is no fallback to &amp;quot;probably fine,&amp;quot; because a fallback to &amp;quot;probably fine&amp;quot; is the precise mechanism by which ungoverned actions execute.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;This is the posture of the pre-execution authority gate, narrowed to the merge boundary: enforcement precedes the mutation, the default is deny, and a merge that cannot be deterministically evaluated does not proceed. The reviewer&amp;#x27;s confidence is an input to be checked, not an authority to be obeyed.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part IV, Receipts Over Logs&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A decision that cannot be inspected is not enforcement; it is a side effect. Every failclosed run emits a structured, inspectable record, what was reviewed, what failed the gate, and why the final status is what it is. This is attestation, not telemetry. A log says an event happened. A receipt says a decision was made, on this input, under this rule, with this outcome, and it can be read back and reproduced.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That distinction is what makes the gate auditable. An auditor does not have to trust that failclosed ran, or that it ran correctly. They read what it decided. The verdict and its grounds are durable artifacts, not transient console output discarded after the build.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part V, The Boundary Is the Example, Not the Point&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The merge is a small case, chosen because it is public and runnable in two minutes. The principle does not depend on the case. Anywhere an AI system&amp;#x27;s output gates a state mutation, a deployment, a payment, a configuration change, a database write, the same structure holds. The model proposes. A deterministic gate disposes. The gate distrusts the model&amp;#x27;s own confidence and defaults to deny when the input is ambiguous. Capability is removed at the boundary, not requested politely from a system free to ignore the request.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;failclosed is not a novel reviewer; better reviewers keep arriving. It is the discipline around the reviewer, the parser, the fail-closed gate, the receipt, and that discipline is the part that does not depend on which model reads the diff this quarter. The model is replaceable. The gate is the product.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;That is the difference between governance and hope. failclosed is public and runnable: github.com/OrionArchitekton/failclosed. The same enforcement model, applied across an enterprise agent stack rather than a single merge gate, is a Runtime Governance Readiness Scan at danmercede.com.&lt;/p&gt;</content:encoded></item>
<item><title>A Governance Claim You Cannot Run Is Advisory</title><link>https://www.danmercede.com/thoughts/2026-06-02-a-governance-claim-you-cannot-run-is-advisory</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-06-02-a-governance-claim-you-cannot-run-is-advisory</guid><pubDate>Tue, 02 Jun 2026 07:00:00 +0000</pubDate><description>A governance claim that cannot be executed and verified is advisory, regardless of how rigorously it is argued. The proof obligation is a runnable artifact, something a skeptic can clone and watch refuse, not a manifesto.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Every strong governance argument eventually meets the same question: show me. A position paper describes what enforcement should do. It does not establish that any system does it. Until the claim reduces to something a reader runs, and watches halt on the case it promised to halt on, it remains advisory, no matter how tight the prose.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The proof obligation for runtime governance is an artifact, not an essay. A skeptic clones it, feeds it the ambiguous input, and sees it default-deny. Doctrine earns its authority by becoming executable: the distance between a claim about enforcement and an enforcement you run is the distance between content and proof.&lt;/p&gt;</content:encoded></item>
<item><title>Distrust the Model&#39;s Own Output Shape</title><link>https://www.danmercede.com/thoughts/2026-05-26-distrust-the-models-own-output-shape</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-05-26-distrust-the-models-own-output-shape</guid><pubDate>Tue, 26 May 2026 07:00:00 +0000</pubDate><description>When a model&#39;s output gates the next step, that output is untrusted input. Strip the fences, validate the schema, and on malformed or self-contradictory output, abstain. The one thing a deterministic gate must never do is pass the model&#39;s verdict through unparsed.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A model that decides whether the next step runs is part of the control path, and its output is input from an untrusted source. It arrives wrapped in markdown fences, truncated by a token limit, carrying a verdict that contradicts its own findings. A gate that reads that output as if it were a clean signal has quietly relocated the enforcement into the model&amp;#x27;s good behavior.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The boundary has to do the unglamorous work: strip the formatting, validate against a schema, and on anything malformed or self-contradictory, refuse rather than guess. The model proposes; the deterministic gate disposes, and it disposes by parsing, not by trusting. Distrusting the shape of model output is not pessimism, it is the only place the control actually lives.&lt;/p&gt;</content:encoded></item>
<item><title>Pre-Execution Authority Gates: Why Governance Must Be Evaluated Before State Mutation</title><link>https://www.danmercede.com/thoughts/2026-05-20-pre-execution-authority-gates</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-05-20-pre-execution-authority-gates</guid><pubDate>Wed, 20 May 2026 07:00:00 +0000</pubDate><description>Governance evaluated after state mutation is telemetry; only deterministic enforcement at the pre-execution boundary is governance.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Part I, Runtime Governance Engineering&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The runtime governance control plane begins with a non-negotiable premise: governance cannot operate as a post-hoc advisory function. Advisory governance depends on observability layers and probabilistic alerts triggered after state mutation has already occurred. This architecture accepts that unauthorized actions can execute before intervention. That latency is systemic risk. Governance evaluated after execution is telemetry. Telemetry is not enforcement. To mitigate systemic risk, governance must exist as a deterministic constraint evaluated strictly before any state mutation occurs.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;When an execution framework treats governance as an external observer rather than a foundational constraint, it inevitably permits untrusted compute nodes to initiate actions that must subsequently be mitigated. True governance demands deterministic enforcement at the execution boundary, ensuring that an agent physically cannot execute a disallowed command. By shifting the evaluation entirely to the pre-execution phase, the system guarantees that no network packet leaves the agent enclave and no database transaction is initialized without explicit cryptographic authorization. The intelligence layer determines its intended action, but the physics of the control plane dictate what is physically possible to execute.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The architecture enforces a non-bypassable control plane. This means the execution substrate physically and cryptographically lacks the network routing, identity credentials, and memory pathways to interact with external APIs or databases directly. To enforce this, AI models and agents are treated as untrusted compute nodes. They are strictly isolated in ephemeral, lane-isolated memory enclaves, implemented natively as distinct namespaces or microVMs.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Within these hardware-isolated lanes, agents are physically incapable of resolving external DNS or establishing direct outbound TCP connections. All tools provided to the agent are actually internal RPC stubs. When an agent attempts an action, it merely passes a structured intent payload to the RPC stub, which forwards it to the control plane. Because the execution node is stripped of routing primitives and external credentials, the authority gate becomes structurally non-bypassable. Every intended action must traverse the control plane, where it is subjected to deterministic evaluation. Enforcement occurs precisely at this juncture: at the structural boundary between the execution substrate and external state or API layers.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The Runtime Governance Control Plane is the sole routing and enforcement boundary. It is explicitly designed to be devoid of probabilistic reasoning; it operates purely on deterministic logic. The Pre-Execution Authority Gate inspects the intended state mutation payload. To ensure that human intent is enforced with sub-millisecond evaluation latency at runtime, high-level human policies are compiled into low-level deterministic rules. These execution roles map to Attribute-Based Access Control (ABAC) and Role-Based Access Control (RBAC) constraints defined in the signed policy.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The validation engine architecture isolates the request payload and evaluates it against the loaded, compiled policy constraints in memory. Simultaneously, signature verification mechanisms verify the identity signature of the requesting agent and check it against the active session ledger. Evaluation logic within this engine is strictly binary. The architecture maintains a default-deny posture; if the request lacks necessary context, or if the policy does not explicitly permit the action, the state is flagged as indeterminate. Ambiguity translates directly to an execution halt. There is no fallback to probabilistic scoring or secondary inference models. The compiled policy must explicitly match the intended intent, or the action is denied at the threshold.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;When validation fails, enforcement is instantaneous and absolute. Enforcement occurs synchronously in memory at RPC ingress. On failure, execution halts instantly. The payload is discarded before routing is ever established. If ambiguity exists in either the execution intent or the policy mapping, the system defaults to a hard execution halt. The state mutation request is dropped, and the execution thread is suspended or terminated.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Any attempt to bypass the RPC stubs and communicate directly with external IP addresses results in packet drops at the container boundary. Furthermore, if a halt is triggered, the engine freezes the agent&amp;#x27;s memory state and opens an escalation pathway, routing the frozen context back to the human authority layer for review. This ensures that the agent cannot autonomously retry or attempt to subvert the blocked request; the execution thread remains cryptographically locked in its frozen state until explicitly unblocked or destroyed by the human-defined escalation protocol.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A deterministic enforcement system requires immutable cryptographic proof that a policy was evaluated and either permitted or blocked. At the Authority Gate layer, the artifact that proves enforcement is a trace log detailing the specific boolean evaluation failure. If an execution boundary bypass is attempted, substrate network egress rejection logs serve as the proof of enforcement.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;For deeper state tracking, the immutable receipts service guarantees that every material action, whether approved or denied, generates a receipt. This architecture provides absolute non-repudiation. Receipts are written to an append-only ledger using cryptographic hash chaining, such as Merkle trees. The receipt journals the state mutation by including the agent&amp;#x27;s exact request payload, the specific policy version evaluated, the cryptographic signatures of both, and the timestamp. For a denied request, the artifact that proves enforcement is the cryptographic hash generated by the Immutable Receipt Service. Because the system requires synchronous receipt generation before finalizing the mutation, no action can occur off the books. If the receipt cannot be written to the ledger, the transaction is rolled back and execution halts. Enforcement is not inferred. It is cryptographically attested by the chained ledger entry representing the constrained state transition.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;If governance is not deterministically enforced before state mutation, it is not governance. It is hope.&lt;/p&gt;</content:encoded></item>
<item><title>The Merge Is a State Mutation</title><link>https://www.danmercede.com/thoughts/2026-05-19-the-merge-is-a-state-mutation</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-05-19-the-merge-is-a-state-mutation</guid><pubDate>Tue, 19 May 2026 07:00:00 +0000</pubDate><description>A merge changes production state, which makes the release gate an authority gate. If the fail-closed merge check cannot resolve, the merge halts. It does not proceed on the assumption that green-enough is good enough.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;Teams guard runtime mutations carefully and then merge to main on a glance at a checkmark. But a merge is a state mutation, often the most consequential one, the moment a change crosses from proposal into what runs. If the gate in front of it cannot conclusively resolve and still lets the merge through, the release boundary is governed by optimism.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The merge gate is an authority gate at a different altitude. Its check evaluates the actual merge state, and on anything it cannot resolve, a degraded signal, an ambiguous result, a missing required check, it halts. Default-deny does not stop at runtime; it belongs at every boundary where intent becomes durable state, and the merge is one of them.&lt;/p&gt;</content:encoded></item>
<item><title>Posted Is Not Published</title><link>https://www.danmercede.com/thoughts/2026-05-12-posted-is-not-published</link><guid isPermaLink="true">https://www.danmercede.com/thoughts/2026-05-12-posted-is-not-published</guid><pubDate>Tue, 12 May 2026 07:00:00 +0000</pubDate><description>A publish receipt earns its terminal state through independent visibility verification or an explicit, documented abort, never through the act of posting. The platform accepting a write is a claim; the content being visible is the proof.</description><content:encoded>&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;A POST that returns 200 means the platform accepted bytes. It does not mean a human will see the content, that it cleared review, or that it was not silently dropped. A pipeline that closes its receipt at &amp;quot;posted&amp;quot; is recording its own intent and calling it an outcome. Posted is a claim; published is a verified fact, and the two are not the same event.&lt;/p&gt;&lt;p class=&quot;my-4 leading-relaxed text-slate-300&quot;&gt;The receipt&amp;#x27;s terminal state has to be earned: an independent check that the content is actually visible, or an explicit, documented abort when the channel cannot be verified. An unverifiable post does not get an assumed-success record, it terminates as aborted against a closure note. Enforcement here is refusing to call a thing done because the easy signal said so.&lt;/p&gt;</content:encoded></item>
</channel>
</rss>
