<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-CA">
  <title type="text">YMRTECH — Notes</title>
  <subtitle type="text">Linux Systems &amp; Cloud Security Engineering</subtitle>
  <id>https://ymrtech.com/notes/</id>
  <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/"/>
  <link rel="self" type="application/atom+xml" href="https://ymrtech.com/feed.xml"/>
  <updated>2026-10-16T00:00:00-04:00</updated>
  <rights>© 2026 Yannick M. Richard</rights>
  <generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator>
  <author>
    <name>Yannick M. Richard</name>
    <email>yannick@ymrtech.com</email>
  </author>
  
  <entry>
    <title type="text">Running a 27B model on a 3090: a budget, not a flag</title>
    <id>https://ymrtech.com/notes/running-a-27b-model-on-a-3090/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/running-a-27b-model-on-a-3090/"/>
    <published>2026-10-16T00:00:00-04:00</published>
    <updated>2026-10-16T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Fitting a 27B model on one 24GB card is two arithmetic unknowns - weight memory at your quantisation and KV cache at your context - and a curve, not a number.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="llm"/>
    
    <category term="cuda"/>
    
    <category term="quantisation"/>
    
    <category term="benchmarking"/>
    
    <content type="html">&lt;p&gt;Fitting a 27B-parameter model on a single consumer card is an arithmetic
exercise, not a configuration one. There are two unknowns: &lt;strong&gt;weight memory at
the quantisation you choose&lt;/strong&gt;, and &lt;strong&gt;KV cache at the context length you actually
want&lt;/strong&gt;. Everything else — offload counts, thread counts, model-loading flags —
is downstream of those two.&lt;/p&gt;

&lt;p&gt;The failure mode is rarely “it does not fit”. It is “it fits at 4k context and
dies at 32k”, or “it fits, and it generates at two tokens per second”. Both are
budget failures, and both are visible before you serve a single request if you
measure instead of guess.&lt;/p&gt;

&lt;p&gt;This is a retired experiment. The GPU box’s local model stack has been switched
off — as of 2026-09-16 the serving endpoint had no listener at all (TCP refused)
and the host’s AI services were disabled. What remains useful is the method and
the harness, not the deployment.&lt;/p&gt;

&lt;h2 id=&quot;the-two-numbers-that-matter&quot;&gt;The two numbers that matter&lt;/h2&gt;

&lt;p&gt;Throughput claims about local inference reduce to two measurements: &lt;strong&gt;tokens per
second (TPS)&lt;/strong&gt; and &lt;strong&gt;time to first token (TTFT)&lt;/strong&gt;. Both must be taken &lt;strong&gt;at the
context length you intend to use&lt;/strong&gt;. A headline TPS on a 512-token prompt is not
evidence about a 32k-token session; it is evidence about a different workload.&lt;/p&gt;

&lt;p&gt;The retired baseline illustrates this because it is one point, not a verdict:
roughly 24 tokens/second decode on a short prompt with a TTFT of 0.36 s, for a
27B model on a 24 GB RTX 3090 with the length ceiling configured at 90000
tokens. That 24 is a &lt;em&gt;point on a curve&lt;/em&gt;. Decode TPS falls as context grows,
because the KV cache competes for the memory the weights occupy — and that curve,
not the number, is the interesting output.&lt;/p&gt;

&lt;p&gt;One trap worth keeping: reasoning models stream their thinking in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delta.reasoning_content&lt;/code&gt;, not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delta.content&lt;/code&gt;. A TPS counter that reads only
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content&lt;/code&gt; reports &lt;strong&gt;zero tokens&lt;/strong&gt; while the model works perfectly — it looks
like a broken server and is a broken counter. Ask the server for exact usage
with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;stream_options&quot;: {&quot;include_usage&quot;: true}&lt;/code&gt; so the final chunk carries the
true prompt-token count and the prefix-cache hits.&lt;/p&gt;

&lt;h2 id=&quot;weight-memory-is-a-budget-line-not-a-setting&quot;&gt;Weight memory is a budget line, not a setting&lt;/h2&gt;

&lt;p&gt;Quantisation trades memory for quality and speed. It is a budget line, not a
preference — and it cannot be chosen independently of the context you need,
because both draw on the same pool. On the 24 GB card this ran on, the 27B model
at the quantisation in use stayed resident at roughly &lt;strong&gt;21 GB of VRAM&lt;/strong&gt;. That
leaves a few gigabytes for the KV cache and compute buffers; a larger context
comes out of that remainder.&lt;/p&gt;

&lt;p&gt;There is a version of this that looks like a flag. The arch pin is real: RTX
3090 is compute capability 8.6, and pinning CUDA architectures to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8.6&lt;/code&gt; rather
than building for every architecture is the single biggest build-time lever. It
also changes the derivation hash of every CUDA package, so the official caches
stop matching and packages like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nccl&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onnxruntime&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;triton&lt;/code&gt; recompile
from source — hours on a small core count. That is a cost you budget, not a
switch you flip.&lt;/p&gt;

&lt;h2 id=&quot;the-term-people-forget&quot;&gt;The term people forget&lt;/h2&gt;

&lt;p&gt;KV cache grows with context length &lt;strong&gt;and with concurrent sessions&lt;/strong&gt;. It is the
term missing from most “will it fit” arithmetic, because it is invisible until
the second request arrives. A configured ceiling of 90000 tokens does not mean a
90k-token session is affordable once the resident weights and the cache share
one card; it means the server will accept it and then decide.&lt;/p&gt;

&lt;p&gt;The budget is a line, not a table:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;VRAM &amp;gt;= weights(quantisation) + KV(context x concurrent sessions) + overhead
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every term on the right is knowable before launch: the 21 GB resident figure is
the first, and the context you need decides the second.&lt;/p&gt;

&lt;h2 id=&quot;measure-the-curve-keep-the-harness&quot;&gt;Measure the curve, keep the harness&lt;/h2&gt;

&lt;p&gt;A single result decays — a driver update, a requantisation, a firmware change
moves it. A benchmark you keep and re-run is worth more, because the output you
care about is the curve across context lengths. This fleet’s standard harness
grows a simulated agent session across roughly twelve turns to about 88k input
tokens — near the 90000 ceiling — then runs needle-recall probes, then replaces
the middle of the history with a lossy summary and re-probes to measure recall
fidelity after compression. Content is deterministic (seeded), so runs are
comparable. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--target&lt;/code&gt; flag and a quick smoke mode cover shorter runs.&lt;/p&gt;

&lt;p&gt;The conclusion is narrow. On a single desktop card, the useful question
is &lt;strong&gt;which context lengths your workload actually needs&lt;/strong&gt; — because that answer,
not the parameter count, determines what you can run at all. The first full run
of the harness has not been completed and published; what exists today is the
method and the file, which is what makes the next run comparable to it.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Run the same harness at two context lengths&lt;/strong&gt; and look at the TPS/TTFT curve
for yourself. One number is an anecdote; two points on the same curve are a
measurement.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Record the quantisation alongside every number.&lt;/strong&gt; A TPS figure without the
quantisation that produced it is not reproducible, because the quantisation
sets the first budget term.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure these services ran on — hosts, GPU modules, the deploy health
gate and its rollback — is described at
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;. The benchmark
above is the same discipline as &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;’s
signed artifacts: measure, keep the artifact, publish the method, and let the
curve, not the headline, be the claim.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Cookieless A/B testing without lying to anyone</title>
    <id>https://ymrtech.com/notes/cookieless-ab-testing/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/cookieless-ab-testing/"/>
    <published>2026-10-15T00:00:00-04:00</published>
    <updated>2026-10-15T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Assign a visitor to a variant from a rotating HMAC key, count the outcome server-side, and hold no persistent identifier — plus what that makes unanswerable.</summary>
    
    <category term="privacy"/>
    
    
    <category term="analytics"/>
    
    <category term="ab-testing"/>
    
    <category term="hmac"/>
    
    <category term="measurement"/>
    
    <content type="html">&lt;p&gt;A/B testing has a reputation it earned. Most of the tooling sold for it answers
“which variant won” by first answering &lt;em&gt;who are you&lt;/em&gt;, and keeping the answer — a
cookie, a session identifier, a device graph — for as long as someone wants to
keep asking. None of that is a requirement of measurement; it is a requirement
of a business model that got bundled with measurement.&lt;/p&gt;

&lt;p&gt;You can assign a visitor to a variant, count the outcome, and hold no identifier
for them — by deriving the assignment from something coarse and rotating instead
of storing it, and by being clear up front about which questions that makes
unanswerable.&lt;/p&gt;

&lt;h2 id=&quot;the-assignment-is-derived-not-stored&quot;&gt;The assignment is derived, not stored&lt;/h2&gt;

&lt;p&gt;The design this site’s stack prescribes, and what its
&lt;a href=&quot;/privacy/&quot;&gt;privacy policy&lt;/a&gt; describes in §2.1, is:
&lt;strong&gt;assign server-side — never a client-side splitter, never a blocking
third-party script.&lt;/strong&gt; The edge service decides which variant a request sees; the
page only reports what happened. Nothing is set on the client at all.&lt;/p&gt;

&lt;p&gt;The assignment key is not an identity. It is an HMAC over a &lt;em&gt;coarse&lt;/em&gt; input — a
client address reduced to a network-sized prefix, plus the coarse browser family
— under a key that rotates on a schedule. Two consequences follow directly:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Within a rotation window the assignment is stable and reproducible from the
request alone, so nothing is remembered between the impression and the
conversion.&lt;/li&gt;
  &lt;li&gt;Across the boundary the same person hashes to a different key. The two windows
cannot be joined back together — by us, or by anyone who later obtains the
logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the mechanism, and it is also the trade. Hash the coarse UA family
&lt;em&gt;alongside&lt;/em&gt; the address, because one NAT or office IP would otherwise pin
everyone behind it to a single arm. Decide the day-bucket timezone once and
document it — UTC avoids the DST cliff where a bucket boundary silently moves.&lt;/p&gt;

&lt;p&gt;Attribution does not depend on the key. The variant rides in the &lt;strong&gt;links&lt;/strong&gt;:
through the CTA, into the signup entry, validated against the declared variant
set, into the payment provider’s session metadata, and out as one structured
conversion line from the payment webhook. The authoritative conversion is that
event — never a click, never a platform-reported number.&lt;/p&gt;

&lt;h2 id=&quot;what-the-rotation-costs&quot;&gt;What the rotation costs&lt;/h2&gt;

&lt;p&gt;The cost is precise, and it belongs in the design document before the test
rather than in the readout afterwards: &lt;strong&gt;you cannot follow a returning visitor
across the rotation boundary.&lt;/strong&gt; Retention-style metrics — did the person who saw
arm B come back ten days later — are out of scope by construction. A
visitor-level split on conversions also needs volume to resolve anything: below
roughly fifty visits a day it cannot detect a sane effect size, and the
move there is to test at content or campaign level and aim the instrumentation
at clicks, scroll and dwell.&lt;/p&gt;

&lt;p&gt;The second cost is caching. Per-visitor content served under the site’s default
cache header means one visitor’s variant is served to everyone, so the response
must carry &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cache-Control: no-store, private&lt;/code&gt; plus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vary&lt;/code&gt; on the header the
proxy itself sets. Bots are served the control variant and marked uncounted, so
they cannot skew one arm.&lt;/p&gt;

&lt;h2 id=&quot;a-system-that-cannot-state-the-question&quot;&gt;A system that cannot state the question&lt;/h2&gt;

&lt;p&gt;Here is the argument underneath this, and it is not a compliance argument. A
measurement system that requires identifying everyone to answer a question is
usually a system that has not defined the question. This design forces the
question into the open — &lt;em&gt;what is the key, how long is it stable, what can we
not measure&lt;/em&gt; — because the answers have to be written down before the first
impression. The questions it refuses are not sacrifices; they are the ones
nobody had specified.&lt;/p&gt;

&lt;p&gt;The same discipline shows up elsewhere here. The fair-share shaper that caps the
VPN tiers keeps &lt;strong&gt;no per-peer traffic record&lt;/strong&gt;, because the no-log posture
forbids it, so fairness comes out of the data path instead: a guaranteed share
plus a burst ceiling, read from nothing but the live counters the kernel holds
in memory. The constraint did not weaken the mechanism. It chose it.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The recorded and not-recorded lists.&lt;/strong&gt; &lt;a href=&quot;/privacy/&quot;&gt;The privacy
policy&lt;/a&gt; §2.1 states exactly what a page
measurement contains — that a page was viewed, the A/B variant when a test is
running, the browser’s own timing, uncaught JavaScript errors — and what it
does not: no cookie, nothing in local or session storage, no IP stored with a
measurement, no cross-site identifier.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The retention numbers.&lt;/strong&gt; Log records are kept 14 days, and the page-timing
measurements travel inside those same records — there is no separate metrics
store for them, so they expire with the logs. The aggregate byte counters are
kept at most 90 days. Every period is enforced by the servers and visible in
the public configuration.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The build gate.&lt;/strong&gt; The measurement library is pinned by hash in the public
repository, and every build re-checks that the served file matches the pin,
that the page sets no cookie and leaves nothing in browser storage, and that
it contacts nothing but this domain’s own collector.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The checklist for any proposed test:&lt;/strong&gt; what is the assignment key, how long
is it stable, what can we not measure, and what gets published.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this claims cookieless measurement is strictly better. It claims the
questions a test cannot answer should be decided &lt;em&gt;before&lt;/em&gt; the test, not
discovered in a dashboard afterwards — the same reason
&lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; is built from signed artifacts
rather than a status badge. A claim you cannot inspect is only a claim about
intentions.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A local DKIM check beats every mail-a-service validator</title>
    <id>https://ymrtech.com/notes/local-dkim-check/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/local-dkim-check/"/>
    <published>2026-10-14T00:00:00-04:00</published>
    <updated>2026-10-14T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A validator tests one message it received. A check on your own receiving host tests your real signing path and records the selector your mail system stamped.</summary>
    
    <category term="security"/>
    
    
    <category term="email"/>
    
    <category term="dkim"/>
    
    <category term="spf"/>
    
    <category term="deliverability"/>
    
    <content type="html">&lt;p&gt;The instinct when mail looks wrong is to send a message to a validator and read the page it prints. It is a reasonable first move and a weak last one. A third-party validator tests &lt;strong&gt;one message it received&lt;/strong&gt;: its own path, its own envelope, its own timing. Your signing path — your selector, your submission route, your relay — is what you have to be able to vouch for, and the check that does that runs on your own host.&lt;/p&gt;

&lt;h2 id=&quot;the-path-you-actually-ship&quot;&gt;The path you actually ship&lt;/h2&gt;

&lt;p&gt;On this fleet, submission is not SMTP from a client to the internet. It is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mail.ymrtech.com:587&lt;/code&gt; STARTTLS over the WireGuard mesh, with SASL &lt;strong&gt;per mailbox&lt;/strong&gt; — so the address that sends and the address that authenticates are two different facts, and both matter. Port 25 does not leave the host (587, 2525, 465 and 80 reach from it; 25 does not), so external delivery goes through a smarthost with a sender-scoped relay map rather than a global &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;relayhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That matters for verification because the relay is part of the path under test. Whatever you measure has to be measured through the pair you actually ship: this mailbox, through this submission port, out through this relay.&lt;/p&gt;

&lt;h2 id=&quot;your-own-host-already-stamps-the-verdict&quot;&gt;Your own host already stamps the verdict&lt;/h2&gt;

&lt;p&gt;Third-party validators exist partly because the receiving side records what it checked. Your receiving side can do that too, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/default.nix&lt;/code&gt; says how:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# rspamd stamps NOTHING for local/authenticated mail ... a purely internal
# submission carries no Authentication-Results ... This host vouches for
# the submission path instead. The stamp lives in the submission-only
# cleanup service, and reaching that service already requires SASL over the
# wg0-only firewall rule below ... Inbound mail from the internet (25) is
# untouched and still gets rspamd&apos;s real verdict.
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;So there are two verdicts to read, and they are not interchangeable. Inbound mail from outside carries the receiving server’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authentication-Results&lt;/code&gt; — real verdicts, and note that such a pass rides on &lt;em&gt;relaxed&lt;/em&gt; DKIM alignment, since a VERP envelope like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bounce.&amp;lt;opaque&amp;gt;@return.&amp;lt;domain&amp;gt;&lt;/code&gt; cannot satisfy &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aspf=s&lt;/code&gt;. Mail handed in over submission is vouched for by a submission-only stamp recording a &lt;strong&gt;true&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smtp.mailfrom&lt;/code&gt; per identity, derived from the account config rather than hand-listed, so adding an alias cannot silently produce mail that looks unauthenticated.&lt;/p&gt;

&lt;p&gt;One caveat, because it wastes sessions: a message delivered to a mailbox on the same server never passes through a receiving MTA’s authentication check. Its headers show &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DKIM-Signature&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Received&lt;/code&gt;, and &lt;strong&gt;no&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authentication-Results&lt;/code&gt;. When someone asks you to send them mail “so I can inspect the headers”, say plainly that a local copy carries no verdict and point them at an external mailbox’s “Show original” — while you verify the signature itself, which needs nobody’s cooperation.&lt;/p&gt;

&lt;h2 id=&quot;read-the-result-with-the-selector-attached&quot;&gt;Read the result with the selector attached&lt;/h2&gt;

&lt;p&gt;A bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dkim=pass&lt;/code&gt; is a weak observation. Record the signer domain, the selector and the body-hash result together, because they fail independently:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The signing config pins domain &lt;em&gt;and&lt;/em&gt; selector: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rspamadm configdump dkim_signing&lt;/code&gt; shows the path and the selector, and the rspamd log prints &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DKIM_SIGNED{ymrtech.com:s=mail}&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;A selector mismatch is what a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dkim=pass&lt;/code&gt; without a selector hides, and it is exactly what a rotation breaks.&lt;/li&gt;
  &lt;li&gt;A body-hash mismatch with a &lt;em&gt;valid&lt;/em&gt; RSA header signature usually means your verifier’s relaxed canonicalization is wrong, not the sender’s. Get the padding check right and the false negative disappears.&lt;/li&gt;
  &lt;li&gt;Check SPF against the envelope sender the relay actually used, not the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;From:&lt;/code&gt; header. Here DMARC passes on SPF alignment alone for mail sent from this host (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aspf=s&lt;/code&gt;, envelope domain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech.com&lt;/code&gt;), which means a broken DKIM costs reputation rather than delivery — a good reason never to use “it arrived” as your DKIM test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;rotation-is-the-outage-you-are-pre-empting&quot;&gt;Rotation is the outage you are pre-empting&lt;/h2&gt;

&lt;p&gt;A DKIM selector change is a mail outage waiting to happen unless both the old and the new selector are published &lt;strong&gt;before&lt;/strong&gt; the switch. A local check is the cheapest way to prove both resolve: query the old name and the new one, and confirm the new key is the complete value, not a truncation.&lt;/p&gt;

&lt;p&gt;That last failure has a signature. The record is 410 characters with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p=&lt;/code&gt; of 392 (an RSA-2048 SPKI), and a record pasted into a DNS user interface is easily cut to the first 255-character chunk — which was the real defect on this domain, not a “stray control byte”, a wrong diagnosis worth remembering. Byte-compare, never eyeball.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The submission shape.&lt;/strong&gt; The mail host comments in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/default.nix&lt;/code&gt;: the submission stamp, the SASL-only firewall rule, and inbound port 25 keeping rspamd’s own verdict.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The signing selector.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rspamadm configdump dkim_signing&lt;/code&gt; on the mail host, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DKIM_SIGNED{...:s=...}&lt;/code&gt; symbol in the rspamd log — the two facts a validator’s web page never shows you.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The key bytes.&lt;/strong&gt; The published TXT against the key file: 410 characters, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p=&lt;/code&gt; of 392, compared programmatically rather than by eye.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then send through the production path, read the verdict your own system stamped, and keep the output — it is the artifact you would have to produce anyway. For how the same discipline applies to a claim you cannot inspect, see &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; and &lt;a href=&quot;/infra/&quot;&gt;how the fleet is configured&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Treating a prober as a customer</title>
    <id>https://ymrtech.com/notes/treating-a-prober-as-a-customer/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/treating-a-prober-as-a-customer/"/>
    <published>2026-10-13T00:00:00-04:00</published>
    <updated>2026-10-13T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">An unauthenticated form endpoint is an abuse vector and a measurement failure at once: every probe looks like a genuine submission in your own metrics.</summary>
    
    <category term="security"/>
    
    
    <category term="forms"/>
    
    <category term="rate-limiting"/>
    
    <category term="measurement"/>
    
    <category term="spam"/>
    
    <content type="html">&lt;p&gt;Look at an unauthenticated write endpoint — a form that sends mail — and you will usually find one problem being discussed and another being missed. The first is abuse: someone finds the URL and uses your endpoint to send things. The second is measurement: every probe of that endpoint looks, in your own numbers, like a person who wanted to reach you.&lt;/p&gt;

&lt;p&gt;The second problem survives fixing the first.&lt;/p&gt;

&lt;h2 id=&quot;where-the-credential-lives-decides-who-can-use-the-endpoint&quot;&gt;Where the credential lives decides who can use the endpoint&lt;/h2&gt;

&lt;p&gt;The site is a static Jekyll build, so the POST goes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/contact/submit&lt;/code&gt;, which Caddy proxies to a small loopback-only listener that relays over the mesh to the fleet’s mail host. The endpoint requires a shared token, and a deploy step is where it becomes real:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# It is deliberately NOT baked into the site (the repo is public and
# the token must be rotatable without a rebuild): it is written here, at
# deploy time ... If the file is absent the endpoint stays locked and the
# form shows the visitor a mailto fallback rather than failing silently.
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That last sentence is the design worth copying. An absent secret is a &lt;strong&gt;fail-closed&lt;/strong&gt; condition: no token is published, the endpoint rejects everything with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;403&lt;/code&gt;, and the page still offers a working mailto link rather than a form that appears to work and loses the message.&lt;/p&gt;

&lt;h2 id=&quot;rejections-should-be-cheap-and-ordered&quot;&gt;Rejections should be cheap, and ordered&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pkgs/contact-form/contact_form.py&lt;/code&gt; states its ordering as a design constraint, and the ordering is the argument: method, content type, content length and rate limit are checked before any parsing, and parsing is capped before any SMTP connection is attempted.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;wrong content type gives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;415&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;a missing or oversized &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Length&lt;/code&gt; (cap: 16 KiB) gives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;413&lt;/code&gt;, refused before the body is read&lt;/li&gt;
  &lt;li&gt;a missing or wrong token gives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;403&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;more than 5 accepted submissions per source address per hour gives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;429&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token is a header the page’s JavaScript sets, not a form field, so it is not part of a plain form POST, and it is compared with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hmac.compare_digest&lt;/code&gt;. One line in the file is the decision most endpoints get backwards:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# Only an authenticated caller consumes rate-limit budget: the limit is
# there to bound a visitor (or a token-scraper), not to let unauthenticated
# probes lock out real submissions.
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A rate limit any anonymous request can spend is a denial-of-service primitive aimed at your own visitors; here the cheap rejections never touch the budget.&lt;/p&gt;

&lt;h2 id=&quot;a-form-in-a-comment-still-passes-a-naive-check&quot;&gt;A form in a comment still passes a naive check&lt;/h2&gt;

&lt;p&gt;The site also ships a gate, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scripts/check-contact-form-visible.sh&lt;/code&gt;, and its header names the mistake it exists to catch: a grep for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;form&lt;/code&gt; passes even when the markup sits inside an HTML comment. So it verifies the way a browser renders — it strips &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;!-- ... --&amp;gt;&lt;/code&gt; pairs, then asserts that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id=&quot;ymrtech-form&quot;&lt;/code&gt; and the controls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;email&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;message&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;website&lt;/code&gt; are all in the &lt;em&gt;visible&lt;/em&gt; remainder, and that a stray opener has not swallowed the page.&lt;/p&gt;

&lt;p&gt;That is the same lesson as the status claims elsewhere on this site: &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; is built from signed artifacts for the same reason — check the artifact in the state it is actually in.&lt;/p&gt;

&lt;h2 id=&quot;the-half-that-survives-the-fix&quot;&gt;The half that survives the fix&lt;/h2&gt;

&lt;p&gt;Now the measurement half. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;403&lt;/code&gt; from a token-less prober and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;200&lt;/code&gt; from a real visitor are both one line against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/contact/submit&lt;/code&gt;. If the number you watch is derived from paths, your funnel counts attackers as demand — and unlike the spam, that does not stop when the endpoint gets better.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Rate-limit by behaviour and by source, not by User-Agent.&lt;/strong&gt; See &lt;a href=&quot;/notes/dont-ban-the-googlebot/&quot;&gt;don’t ban the Googlebot&lt;/a&gt;: that header is chosen by the client.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Separate “a request arrived” from “a person tried to reach us” before counting anything.&lt;/strong&gt; The endpoint’s own rejections are the interesting signal, and here they go to its journal — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bad token from &amp;lt;ip&amp;gt;&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rate limited &amp;lt;ip&amp;gt;&lt;/code&gt; — while the host’s log collector picks up Caddy access logs rather than service stdout. Rejections are legible on the host, not in the log store, until the collector’s glob is extended.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Treat the form’s success metric as untrusted until you can say what was filtered.&lt;/strong&gt; A conversion count you cannot decompose counts traffic, not people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The asymmetry: you can reduce spam and clean up your metrics, but a public unauthenticated endpoint will keep receiving some non-human traffic. The useful goal is not zero — it is that none of it contaminates a decision. The same trap is in &lt;a href=&quot;/notes/a-test-request-is-a-real-request/&quot;&gt;a test request is a real request&lt;/a&gt;: an observation made from the wrong position is evidence about the position.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The ordering, in the file.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pkgs/contact-form/contact_form.py&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config&lt;/code&gt; repository: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;415&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;413&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;403&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;429&lt;/code&gt; sequence and the comment that anonymous probes do not spend rate-limit budget.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The fail-closed deploy.&lt;/strong&gt; The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Publish contact-form token&lt;/code&gt; step in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech-deploy.yml&lt;/code&gt; warns and publishes an empty token when the host-readable file is unavailable — and the endpoint then stays locked.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The visibility gate.&lt;/strong&gt; Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scripts/check-contact-form-visible.sh&lt;/code&gt; against a built &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_site/contact/index.html&lt;/code&gt;; it names the specific control it cannot find.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For how a missing token and a missing log line are recorded as evidence rather than assumed, see &lt;a href=&quot;/logging/&quot;&gt;the logging stack&lt;/a&gt; and &lt;a href=&quot;/notes/what-no-logs-cannot-promise/&quot;&gt;what no logs cannot promise&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Why the key that decrypts everything is not in the backup</title>
    <id>https://ymrtech.com/notes/key-that-decrypts-everything/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/key-that-decrypts-everything/"/>
    <published>2026-10-12T00:00:00-04:00</published>
    <updated>2026-10-12T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">If the credential that unwraps your backup lives inside it, a total host loss leaves an archive nobody can open. Escrow the identity, then test the restore.</summary>
    
    <category term="security"/>
    
    
    <category term="backup"/>
    
    <category term="restic"/>
    
    <category term="sops"/>
    
    <category term="recovery"/>
    
    <content type="html">&lt;p&gt;A backup is a claim about the future: that on the day it matters, the bytes come back. The interesting failures are not disk failures — they are dependency cycles you built yourself and did not notice.&lt;/p&gt;

&lt;h2 id=&quot;the-repository-password-is-a-secret&quot;&gt;The repository password is a secret&lt;/h2&gt;

&lt;p&gt;This fleet backs up to Cloudflare R2 with restic: file state every 15 minutes with a 90-second jitter, the database dump daily (since 2026-09-29 in a unit of its own, so it is no longer regenerated inside every backup run). The module is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/fleet-backup.nix&lt;/code&gt;, and its header states the intent in one line:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# Repo passphrase is `restic-passwd` in sops (shared fleet-wide, on purpose:
# any host must be able to restore any other host&apos;s repo during recovery).
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two consequences follow immediately. The passphrase is not per-host, so it cannot be per-host-recoverable. And it is a sops secret, so reading it requires an age identity — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/sops-nix/keys.txt&lt;/code&gt;, root-owned &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0600&lt;/code&gt;, plaintext on disk.&lt;/p&gt;

&lt;p&gt;The recovery path is therefore: to read the backup you need the passphrase, and to read the passphrase you need the identity.&lt;/p&gt;

&lt;h2 id=&quot;read-that-as-a-cycle-check&quot;&gt;Read that as a cycle check&lt;/h2&gt;

&lt;p&gt;Both ends of that chain are things you could carelessly store inside the backup. The module refuses both:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# NOTE on /var/lib/sops-nix: this dir holds the host&apos;s age identity
# (/var/lib/sops-nix/keys.txt, root:root 0600) which decrypts secrets.yaml.
# It is PLAINTEXT on disk, so it is deliberately NOT backed up raw here:
# the restic passphrase is shared fleet-wide, so a raw copy would place an
# unencrypted private key in R2, readable by any host holding that passphrase.
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is the whole argument in four sentences. Because the passphrase is deliberately shared, a raw copy of the identity would be readable by anything that can read the archive — the backup would contain the key to the backup. The path set is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/acme&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/caddy&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/nix/netrc&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/hermes&lt;/code&gt;, plus a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_dumpall&lt;/code&gt; on Postgres hosts, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/sops-nix&lt;/code&gt; is not in it. The raw Postgres data directory is excluded too, because it is version-fragile; the dump is authoritative.&lt;/p&gt;

&lt;p&gt;The identity cannot live only on the hosts either. Each host generates its own age key &lt;em&gt;on itself&lt;/em&gt; and the private half never leaves the machine; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops.age.sshKeyPaths = [ ]&lt;/code&gt; on every host, so the ssh host key is no longer convertible into an age identity. A terminated host therefore cannot supply its own key.&lt;/p&gt;

&lt;h2 id=&quot;escrow-and-each-files-deliberate-omission&quot;&gt;Escrow, and each file’s deliberate omission&lt;/h2&gt;

&lt;p&gt;The answer is in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt;, as a second family of creation rules:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# Disaster-recovery escrow of each host&apos;s age identity. Owner + operator&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# only: no host can unwrap another host&apos;s key.&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;path_regex&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;secrets/age-escrow/[^/]+\.age$&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;key_groups&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;age&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;*admin_truva&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;*hermes_agent&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One rule, no per-host exclusions to get wrong — and the recipient list got &lt;em&gt;shorter&lt;/em&gt; rather than longer, deliberately. The first version of this escrow encrypted each host’s identity to every &lt;em&gt;other&lt;/em&gt; host, on the reasoning that the host which dies cannot unwrap its own key, so a surviving peer should be able to. That reasoning builds a cross-host escalation path: any compromised host can unwrap every other host’s identity. The escrow is now readable by the owner and the operator only, and the fleet was re-keyed on 2026-09-23 so that the peer-encrypted ciphertext is retired rather than merely unused — the identities it belongs to exist nowhere in the fleet, and no host is a recipient of any escrow blob.&lt;/p&gt;

&lt;p&gt;Note where the escrow lives: in the &lt;em&gt;private&lt;/em&gt; configuration repository as sops blobs under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/age-escrow/&amp;lt;host&amp;gt;.age&lt;/code&gt;, so it is recoverable from git alone, without depending on R2 — the system that would be the problem in the scenario the escrow exists for. The one piece that is genuinely human-held is the owner identity, which decrypts the store itself.&lt;/p&gt;

&lt;p&gt;The tension is worth saying out loud rather than smoothing over: escrowing a key moves risk from the archive to wherever the escrow lives. A sops blob is safe only because of the recipients listed in it, and a recipient list of two people means the store’s availability rests on those two people. That location needs the highest protection in the system, and it is not the backup server.&lt;/p&gt;

&lt;h2 id=&quot;a-backup-you-have-never-restored&quot;&gt;A backup you have never restored&lt;/h2&gt;

&lt;p&gt;The only claim worth making about a backup is a restore you have actually performed. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/disaster-recovery.md&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config-private&lt;/code&gt; is written as a runbook — provision the instance, restore the identity, apply the flake, restore state, rejoin the tunnel, repoint DNS, verify — with a recovery point of 15 minutes for file state and a day for the database dump, and a recovery-time target under an hour for a full host rebuild. It also records what is deliberately not backed up, which is the part most runbooks leave out.&lt;/p&gt;

&lt;p&gt;It ends with a block titled &lt;em&gt;Verify this before you need it&lt;/em&gt;, and the commands are the point:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;sops -d --input-type binary --output-type binary secrets/age-escrow/vpn.age &amp;gt;/dev/null &amp;amp;&amp;amp; echo escrow-ok
age-keygen -y &amp;lt;(sops -d --input-type binary --output-type binary secrets/age-escrow/vpn.age)
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The printed public key must equal that host’s anchor in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt;, and it has to be run where the owner or operator identity is present: hosts are no longer recipients of any escrow blob, including their own. That is a two-line test of the step the runbook labels “WITHOUT THIS NOTHING WORKS”, and it is worth running on a schedule — because a runbook nobody has exercised is documentation of an intention, and a backup system’s documentation is a restore runbook plus the date it was last exercised.&lt;/p&gt;

&lt;p&gt;As of 2026-09-27 there is a date to quote: repository integrity checked on all three hosts, and a file-level restore performed on each, with the results recorded in the &lt;a href=&quot;/audit/isms/backup-recovery/&quot;&gt;backup and recovery statement&lt;/a&gt;. There is now a second date for the harder half. On 2026-09-28 both database hosts were restored from the physical repository and recovered to a chosen point in time — the restore that matters when a record is wrong rather than when a host is gone — and its measured durations are recorded in the same place. What neither drill shows — a full host rebuild — is stated in the same place, next to them.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The cycle, in the module.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config/modules/fleet-backup.nix&lt;/code&gt;: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths = [ ... ]&lt;/code&gt; list, and the comment explaining why &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/lib/sops-nix&lt;/code&gt; is absent from it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The escrow rules.&lt;/strong&gt; Not publicly checkable: they live in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config-private/.sops.yaml&lt;/code&gt;, and the blobs under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/age-escrow/&lt;/code&gt; are in the same private repository. What the public repository carries is each host’s public identity anchor in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt;, which is what the verify block compares against.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;That the escrow is testable today.&lt;/strong&gt; The verify block in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/disaster-recovery.md&lt;/code&gt;, run as the owner or the operator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the sibling problem — a status you can inspect versus one you have to trust — &lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;the green deploy that deployed nothing&lt;/a&gt; makes the same argument about a CI badge. The rest of the host configuration is described in &lt;a href=&quot;/infra/&quot;&gt;how the fleet is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Don&apos;t ban the Googlebot</title>
    <id>https://ymrtech.com/notes/dont-ban-the-googlebot/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/dont-ban-the-googlebot/"/>
    <published>2026-10-11T00:00:00-04:00</published>
    <updated>2026-10-11T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Blocking crawlers by User-Agent stops only the crawlers that identify themselves. The durable control is verified source ranges, pinned by hash.</summary>
    
    <category term="security"/>
    
    
    <category term="crowdsec"/>
    
    <category term="crawlers"/>
    
    <category term="filtering"/>
    
    <category term="false-positives"/>
    
    <content type="html">&lt;p&gt;A catch-all edge that feeds a deception dashboard and CrowdSec is a good place to notice a category error. The rule that gets proposed is almost always the same: block the scanners by User-Agent. It takes a minute to write, it never fires wrongly on your own browser, and it is close to worthless.&lt;/p&gt;

&lt;h2 id=&quot;the-client-chooses-the-string&quot;&gt;The client chooses the string&lt;/h2&gt;

&lt;p&gt;A User-Agent header is text the client writes. Any scanner can send &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Googlebot&lt;/code&gt;, and the ones that matter usually do, because impersonating a crawler is a cheap way past naive rules. So a User-Agent deny rule filters on a field controlled by the party you are filtering: it blocks the crawlers that identify themselves, and repels nobody who will not simply pick another string.&lt;/p&gt;

&lt;p&gt;Which means the block lands on the traffic that pays for your site, not on the traffic you were worried about.&lt;/p&gt;

&lt;h2 id=&quot;what-can-be-decided-and-by-whom&quot;&gt;What can be decided, and by whom&lt;/h2&gt;

&lt;p&gt;There is one fact about a crawler that the client cannot forge: the address it connects from. The publishers document the ranges they originate from, so this fleet reads those documents instead of guessing.&lt;/p&gt;

&lt;div class=&quot;language-nix highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nv&quot;&gt;sources&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nv&quot;&gt;googlebot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://developers.google.com/search/apis/ipranges/googlebot.json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;sha256-n17GcnxmJ4244sglbrP0VzILCXkWbiBJ/ka6nmUOgu8=&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
  &lt;span class=&quot;nv&quot;&gt;googleSpecialCrawlers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://developers.google.com/search/apis/ipranges/special-crawlers.json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
  &lt;span class=&quot;nv&quot;&gt;bingbot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://www.bing.com/toolbox/bingbot.json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/global/crawler-ranges.nix&lt;/code&gt; fetches three manifests and turns each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prefixes&lt;/code&gt; list into CIDRs. The ranges are &lt;strong&gt;not&lt;/strong&gt; copied into the repository. They are fetched at evaluation time and pinned by SRI hash, and the header says exactly why: a publisher that silently edits its ranges becomes a hash mismatch — a build failure a human reviews — instead of a silent widening, or a silent narrowing, of the fleet’s own defence.&lt;/p&gt;

&lt;p&gt;A security control you cannot diff is a control you cannot review. A list of ranges that changes underneath you is not data you own; it is a number you hope is right. Pinning the bytes makes the change visible. It also makes the fetch a fixed-output derivation, so the URL is contacted only on a cold cache or a deliberate hash bump, not on every rebuild.&lt;/p&gt;

&lt;h2 id=&quot;fail-closed-and-where-rdns-belongs&quot;&gt;Fail closed, and where rDNS belongs&lt;/h2&gt;

&lt;p&gt;Two details turn the module from a list into a control.&lt;/p&gt;

&lt;p&gt;If a manifest stops having the documented shape, evaluation aborts with the URL that changed, so the failure reads &lt;em&gt;review this publisher’s new format&lt;/em&gt; rather than &lt;em&gt;defend with an empty list&lt;/em&gt;. And every derived entry must match &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[0-9a-fA-F:.]+/[0-9]{1,3}&lt;/code&gt;, because CrowdSec parses whitelist entries with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;netip.ParsePrefix&lt;/code&gt;: a bare address makes the whole postoverflow file fail to compile and the agent refuses to start, which took this fleet down once. Catching it at evaluation means the error names the publisher instead of a truncated store hash.&lt;/p&gt;

&lt;p&gt;Reverse DNS is deliberately &lt;strong&gt;not&lt;/strong&gt; required on top. These are the publishers’ own ranges, and at BGP level nobody else originates from them, so a forward-confirmed PTR adds no assurance here — it would only lose the whitelist whenever reverse DNS is slow or missing for a legitimate crawler. The PTR check belongs with the publishers who document no ranges at all; CrowdSec’s hub &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;seo-bots-whitelist&lt;/code&gt; matches crawlers by PTR suffix, which is the right check for them.&lt;/p&gt;

&lt;p&gt;The consumer is a CrowdSec whitelist of its own, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fleet/whitelist-verified-crawler-ranges&lt;/code&gt;, so a verified crawler never becomes a decision, whatever a scenario says. It is a separate whitelist rather than extra entries on the fleet’s own-infrastructure list because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fleet.crowdsec.whitelistCidrs&lt;/code&gt; is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;listOf&lt;/code&gt; option with a non-empty default: assigning it from a second module replaces that default outright and silently drops the mesh range. Every whitelist is evaluated independently, so an extra entry is equivalent and cannot clobber the first.&lt;/p&gt;

&lt;h2 id=&quot;the-asymmetry-and-the-limits&quot;&gt;The asymmetry, and the limits&lt;/h2&gt;

&lt;p&gt;The cost of a false block is not symmetric. A blocked malicious client changes its User-Agent and is back in a second. A blocked search crawler is invisible to you — no error page, no alert, no mail — and expensive to detect, because the only symptom is traffic that never arrives.&lt;/p&gt;

&lt;p&gt;Two limits are worth stating rather than hiding. An address inside a publisher’s range is not a personality: range allow-listing cannot tell two clients behind the same address apart, so it is a statement about origin, not intent. And an allow-list is a standing exemption, so it needs an owner and a review. The procedure attached to this fleet’s allowlist pull requests is exactly that discipline: the acceptable diff is one appended line, and anything unidentifiable escalates to a human instead of being merged.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The module, in full.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config/hosts/common/global/crawler-ranges.nix&lt;/code&gt;. The three &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hash =&lt;/code&gt; values and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;throw&lt;/code&gt; on an unexpected manifest shape are the whole argument in code.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;That the ranges are the publishers’ own.&lt;/strong&gt; Each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;url&lt;/code&gt; is a manifest you can fetch yourself; the hash only makes a change visible.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;That the whitelist is separate.&lt;/strong&gt; Search the module for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;s01Whitelist&lt;/code&gt;; the entry is named &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fleet/whitelist-verified-crawler-ranges&lt;/code&gt;, not the own-infrastructure list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule: filter by behaviour and by verifiable origin, keep the allow-list small, reviewed and diffable, and never ship a User-Agent deny rule as your security story. For the rest of the defence — the bans, the log line, the dashboard — see &lt;a href=&quot;/logging/&quot;&gt;the logging stack&lt;/a&gt; and &lt;a href=&quot;/infra/&quot;&gt;how the fleet is configured&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A test request is a real request</title>
    <id>https://ymrtech.com/notes/a-test-request-is-a-real-request/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/a-test-request-is-a-real-request/"/>
    <published>2026-10-10T00:00:00-04:00</published>
    <updated>2026-10-10T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A filtering resolver has no test mode: your test query is answered by real policy, and often by an exemption that hides the policy from you.</summary>
    
    <category term="networking"/>
    
    
    <category term="dns"/>
    
    <category term="adblocking"/>
    
    <category term="filtering"/>
    
    <category term="verification"/>
    
    <content type="html">&lt;p&gt;“I tested it and it worked” is one of the most reliable sentences in
operations, and one of the least informative. A filtering resolver has no
simulation layer: the query you send is a query, the answer is a policy
decision, and whether it is the one your users get depends on &lt;strong&gt;who you were
when you asked&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;there-is-no-test-mode&quot;&gt;There is no test mode&lt;/h2&gt;

&lt;p&gt;A filtering resolver has exactly one execution path. A request to allow a
name — here, a client proposing a whitelist entry from the portal — proceeds
down it as production traffic would: the submission becomes a pull request on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config-private&lt;/code&gt; appending one line to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/adguard/allowlist.txt&lt;/code&gt;,
that line reaches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, the deploy renders it into the resolvers, and the
request mails the address on record.&lt;/p&gt;

&lt;p&gt;The review procedure for those PRs says it in one line: &lt;em&gt;the submit path has no
test mode — whatever you submit becomes a PR, reaches the fleet policy on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, is deployed, and mails the address on the request.&lt;/em&gt; Hence the reserved
host, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whitelist-test.ymrtech.com&lt;/code&gt;, already in the allowlist, instead of a name
you would refuse.&lt;/p&gt;

&lt;h2 id=&quot;an-exemption-hides-the-policy-from-the-tester&quot;&gt;An exemption hides the policy from the tester&lt;/h2&gt;

&lt;p&gt;A filtering resolver applies policy &lt;strong&gt;per client&lt;/strong&gt;. That is a feature: the tier
a device runs on decides which lists answer it. It is also the trap. If the
client you test from is exempt from the rule you are testing — a different
tier, an allowlisted source, a bypass the operator added — your test cannot
observe the rule at all. It observes the exemption.&lt;/p&gt;

&lt;p&gt;This is not specific to DNS. &lt;strong&gt;Any test performed by an actor privileged
relative to the production path is evidence about the test, not about
production.&lt;/strong&gt; The operator’s own machine is the classic privileged actor: it
sits on a different resolver address, may carry settings no customer has, and
is the client an ad-adjacent domain is most likely to have been allowlisted for
by hand.&lt;/p&gt;

&lt;p&gt;So “the client told me it is broken, I checked and it works” usually means you
asked from a position where the answer is different.&lt;/p&gt;

&lt;h2 id=&quot;the-cache-is-a-third-party&quot;&gt;The cache is a third party&lt;/h2&gt;

&lt;p&gt;A resolver cache serves whatever it already holds, and those entries outlive
the change you are verifying. This fleet’s loopback resolvers carry
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cache-min-ttl = 60&lt;/code&gt;. AdGuard Home also rewrites its own config in place and
reloads its filter lists from the URL declared in the policy file before it
serves them, so a retry loop against a resolver mid-reload produces a spurious
empty answer that reads as “blocked”.&lt;/p&gt;

&lt;p&gt;A name that fails right after a policy change may be failing from cache; a name
that resolves may be resolving from cache from before the policy existed.
Either way you measured a cache, or an exemption, and called it the policy.&lt;/p&gt;

&lt;h2 id=&quot;how-this-class-of-request-should-be-reviewed&quot;&gt;How this class of request should be reviewed&lt;/h2&gt;

&lt;p&gt;An allowlist entry is a lasting, reviewable change to a security control, so it
deserves the scrutiny any policy edit gets. The procedure attached to these PRs
asks four things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Who asked, and why?&lt;/strong&gt; A vendor portal, a SaaS the client uses, a school or
employer host, or a CDN for one of those is plausible. A host that does not
resolve, or a reason that does not match it, is not.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What is the blast radius?&lt;/strong&gt; One line in one shared file changes answers on
every instance in the fleet, not just the requester’s device: the lists are
declared in one place and deliberately not overridable per host.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Is the block load-bearing?&lt;/strong&gt; An ad exchange, an analytics or telemetry
network, a tracker — the block exists on purpose and stays. Same for
credential, auth and banking hosts without clear subscription context: those
escalate to a human.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Is the diff exactly what it claims?&lt;/strong&gt; One appended rule, no reordering, no
removals, no second file. Anything else is a red flag before it is a
question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule underneath all four: a request is a policy proposal, judged on the
host and the reason, never on the fact that someone tested it and it worked.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;p&gt;How to tell whether your own test request was privileged — three questions,
where any “yes” invalidates the result:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Which client did you ask from?&lt;/strong&gt; If it was not the device whose experience
you care about — a laptop in the same tier, a phone on the same resolver —
you tested a neighbour. Ask from the failing client.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Which resolver answered?&lt;/strong&gt; Print the address your client actually queries.
A machine pointed at the operator’s resolver is a different question from a
client on the tier in question.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Was caching in play?&lt;/strong&gt; Ask for a name that cannot already be in any cache,
and re-run after the policy settles rather than while the resolver is still
loading rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then record the response code: “blocked” and “not found” are different
failures, and only a recorded code keeps them apart later. This is the same
discipline as
&lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;: a check that runs from the
client’s position and publishes its own result beats any number of successful
probes from somewhere convenient.&lt;/p&gt;

&lt;p&gt;For a related case of the same mistake — a green status that measured the test
harness — see
&lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;the green deploy that deployed nothing&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The DNSSEC trap in a zone you answer yourself</title>
    <id>https://ymrtech.com/notes/dnssec-trap-in-a-zone-you-answer-yourself/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/dnssec-trap-in-a-zone-you-answer-yourself/"/>
    <published>2026-10-09T00:00:00-04:00</published>
    <updated>2026-10-09T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Signing a zone your own resolvers answer converts a soft failure into a hard one: a validating resolver returns SERVFAIL, and the name dies everywhere at once.</summary>
    
    <category term="networking"/>
    
    
    <category term="dns"/>
    
    <category term="dnssec"/>
    
    <category term="split-horizon"/>
    
    <category term="resolvers"/>
    
    <content type="html">&lt;p&gt;There is a failure mode that looks like an outage and is not one. A name that
resolved for months stops resolving everywhere at the same instant — not for
one client, but for every machine that asks. The zone is fine, the records are
fine, nothing was deleted. What changed is that somewhere in the answer path a
resolver tried to &lt;em&gt;prove&lt;/em&gt; the answer, could not, and said so.&lt;/p&gt;

&lt;p&gt;DNSSEC is why, and the trap is that the failure is not proportional to the
mistake.&lt;/p&gt;

&lt;h2 id=&quot;servfail-is-not-nxdomain&quot;&gt;SERVFAIL is not NXDOMAIN&lt;/h2&gt;

&lt;p&gt;A validator that cannot build a chain of trust from the root to the answer
does not serve the unsigned answer as a courtesy. It returns &lt;strong&gt;SERVFAIL&lt;/strong&gt;.
The point of validation is to refuse answers it cannot check.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;# A validating resolver, asked for a name it cannot validate&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;dig +short ymrtech.com
&lt;span class=&quot;c&quot;&gt;# (nothing — rcode SERVFAIL)&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# The same name, asking the resolver to skip validation&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;dig +short +cd ymrtech.com
11.0.0.7
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Those two commands are the whole diagnosis. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+cd&lt;/code&gt; sets the &lt;em&gt;checking disabled&lt;/em&gt;
bit, so it shows what the resolver could actually find: if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+cd&lt;/code&gt; answers and
the plain query does not, the data is present and &lt;em&gt;validation&lt;/em&gt; is what failed.
The records were never the problem.&lt;/p&gt;

&lt;p&gt;At the client, SERVFAIL is indistinguishable from a broken zone: “the name
does not exist” is what you see, and it is wrong. It resolved a minute ago, it
resolves from the server; only the validator differs.&lt;/p&gt;

&lt;h2 id=&quot;a-zone-you-answer-yourself&quot;&gt;A zone you answer yourself&lt;/h2&gt;

&lt;p&gt;This fleet answers its own public names internally. The list lives in one
place, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/global/internal-dns-zone.nix&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;primary&lt;/strong&gt; — AdGuard Home on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;11.0.0.1:53&lt;/code&gt;, upstream unbound on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:5353&lt;/code&gt;,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;standby&lt;/strong&gt; — AdGuard Home on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;11.0.0.7:53&lt;/code&gt;, upstream unbound on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;:5335&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both read the same records, so a client pointed at either address gets the same
answers. Without them, a fleet host talking to one of our own services leaves
through the provider’s NAT and re-enters with an external source address.&lt;/p&gt;

&lt;p&gt;But &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech.com&lt;/code&gt; is a DNSSEC-signed public zone, and this is where the trap
closes: an internal answer carrying a private address has no signature a public
validator can accept — there is nothing to check. So the resolver does the only
thing it is allowed to do:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;It answers SERVFAIL, not the record.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is the comment on the zone definition, written when the overrides
were added. The remedy it prescribes is one option on the validating hosts — in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/giga/default.nix&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-nix highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;# The internal ymr zone is unsigned and answered through AdGuard Home, which&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# does not forward DNSSEC records. With the root trust anchor loaded (nixpkgs&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# default), the validator answered SERVFAIL for every *.ymr name (verified:&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# +cd resolves, plain query fails).&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;domain-insecure&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ymr&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ymrtech.com&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/default.nix&lt;/code&gt; carries the identical line with the identical
reasoning, and both cite the same verification: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+cd&lt;/code&gt; resolved to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;11.0.0.7&lt;/code&gt;
while the validating query failed. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public&lt;/code&gt; host never hits this at all —
it serves &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymr&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;local-zone &quot;ymr.&quot; static&lt;/code&gt;, so the zone never reaches a
validator. Three hosts, three relationships to the same records, and only the
two that forward needed the declaration.&lt;/p&gt;

&lt;h2 id=&quot;the-trade-not-the-mistake&quot;&gt;The trade, not the mistake&lt;/h2&gt;

&lt;p&gt;It is tempting to file this under “we broke DNS with DNSSEC”. That reading
hides the useful part. DNSSEC buys an answer you can trust and charges a hard
failure mode for every break in the chain: a record that is stale, unreachable,
or answered from a split-horizon view used to degrade softly — a client fell
back, an old address kept working. Under validation there is no soft option. A
hairpin answer (internal clients served public addresses, or the reverse) is
precisely what a validator rejects, so it does not degrade; it disappears.&lt;/p&gt;

&lt;p&gt;The trade is worth paying when the trust anchors are agreed everywhere, and it
is a trap when they are not.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Ask for SERVFAIL specifically, not just NXDOMAIN.&lt;/strong&gt; When a name that used
to resolve stops resolving, run both queries above against the resolver the
affected client actually uses. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+cd&lt;/code&gt; answering where the plain query does not
is the signature.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Test from a real client, not from the server.&lt;/strong&gt; Whether validation
succeeds depends on the resolver in the path, so a query from the
authoritative side proves nothing about the laptop. Point a client at the
resolver it uses in production and repeat.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Look for the declaration before you look for the bug.&lt;/strong&gt; On this fleet,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;domain-insecure&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/&amp;lt;host&amp;gt;/default.nix&lt;/code&gt; decides whether an internal
answer survives validation. If a host validates and answers internal names,
those two facts need to agree in writing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Prefer unsigned internal zones unless everything agrees:&lt;/strong&gt; keep a zone you
answer yourself unsigned, or make sure every resolver and every client that
validates shares the same trust anchors. Half-signed is the state that
produces the outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure is invisible from the middle: nothing in a zone file, a record list
or a service page shows an answer being refused for lack of a signature. That
is the same argument behind &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;: a
claim you cannot inspect from the client’s position is only a claim about the
server’s intentions.&lt;/p&gt;

&lt;p&gt;For the wider resolution picture, see
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The config change that never restarted</title>
    <id>https://ymrtech.com/notes/config-change-that-never-restarted/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/config-change-that-never-restarted/"/>
    <published>2026-10-08T00:00:00-04:00</published>
    <updated>2026-10-08T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">On NixOS a config change edits a file, not a running process. Without restartTriggers the deploy is green and the old configuration stays resident in memory.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="systemd"/>
    
    <category term="nixos"/>
    
    <category term="deploy"/>
    
    <category term="verification"/>
    
    <content type="html">&lt;p&gt;On a declarative system it is tempting to believe that editing a file &lt;em&gt;is&lt;/em&gt; the
change. On NixOS it is not. A config file is content, and nothing restarts the
process holding the old copy unless the unit says so. The deploy can be green,
the file can be correct, and the running system can be unchanged.&lt;/p&gt;

&lt;p&gt;The fleet’s own notes have the incident that makes this concrete, and it is
worth quoting exactly.&lt;/p&gt;

&lt;h2 id=&quot;the-feature-that-never-appeared&quot;&gt;The feature that never appeared&lt;/h2&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hermes-agent&lt;/code&gt; module renders its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings&lt;/code&gt; into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.yaml&lt;/code&gt; — but
defines no restart trigger. The observed result: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.yaml&lt;/code&gt; contained a new
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platforms.email&lt;/code&gt; block while the gateway was still the pre-deploy process
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecMainStartTimestamp&lt;/code&gt; from the previous day, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NRestarts=0&lt;/code&gt;), and “the feature
just never appeared.” The file had the change. The process did not.&lt;/p&gt;

&lt;p&gt;That is the whole failure: a config-only deploy that reports success, and a
long-lived process still answering from the configuration it loaded a day
earlier. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EnvironmentFile&lt;/code&gt;-style configuration behaves the same way — it is read
at start, so an edited file has no effect until the unit restarts.&lt;/p&gt;

&lt;h2 id=&quot;what-nixos-gives-you-and-what-it-does-not&quot;&gt;What NixOS gives you, and what it does not&lt;/h2&gt;

&lt;p&gt;NixOS offers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restartTriggers&lt;/code&gt; for exactly this: a unit carrying
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restartTriggers = [ ... ]&lt;/code&gt; restarts when the content of those files changes,
which is how a change to a generated config file pulls a restart behind it.
Without a trigger, nothing does.&lt;/p&gt;

&lt;div class=&quot;language-nix highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nv&quot;&gt;systemd&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;hermes-agent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;restartTriggers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;pkgs&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;writeText&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;hermes-agent-settings-trigger&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;builtins&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;toJSON&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;settings&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;hermes-agent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;environment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;hermes-agent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two details from that wiring matter. The trigger hashes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;environment&lt;/code&gt; as well as
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;settings&lt;/code&gt;, because an env-only change would otherwise go stale — and the
definition has to be gated on whether the module is enabled, because on a host
without it the reference fails evaluation.&lt;/p&gt;

&lt;p&gt;The other half is the reload path. A unit with a working &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecReload&lt;/code&gt; can pick up
a config change without a restart; here &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unbound&lt;/code&gt; is left to reload rather than
restart so it keeps its resolver cache, set with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd.services.unbound.reloadIfChanged = true&lt;/code&gt; on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mail&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;giga&lt;/code&gt;
hosts. But a unit &lt;em&gt;without&lt;/em&gt; a usable reload action will accept a reload request
and do nothing useful — a no-op that looks like success. And a graceful reload
is not always enough: the notes record that Caddy’s graceful reload does not make
it re-read a &lt;em&gt;new&lt;/em&gt; user-supplied &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tls&lt;/code&gt; file pair, and only a restart does.&lt;/p&gt;

&lt;h2 id=&quot;verify-the-process-not-the-file&quot;&gt;Verify the process, not the file&lt;/h2&gt;

&lt;p&gt;The check that decides is the running system, not the artifact on disk:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;systemctl show &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; ExecMainStartTimestamp &amp;lt;unit&amp;gt;   &lt;span class=&quot;c&quot;&gt;# when the process actually started&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;stat&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%y %n&apos;&lt;/span&gt; /path/to/the/config/file          &lt;span class=&quot;c&quot;&gt;# when the file actually changed&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If the process started &lt;em&gt;before&lt;/em&gt; the file changed, the process is running the old
configuration — whatever the file says. Where a service can report its loaded
value, read that instead of the file: a runtime probe that echoes the loaded
configuration is a claim about the process, and the file is only a claim about
the disk.&lt;/p&gt;

&lt;h2 id=&quot;the-principle&quot;&gt;The principle&lt;/h2&gt;

&lt;p&gt;“The file changed” and “the running system changed” are different claims. Only
the second is what you meant when you opened the editor, and only the second is
worth putting in a change record: a file in the store is an input to a deploy,
and the process that started after it is the evidence that the deploy did
something.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Pick a service, change something in its configuration, deploy, and see whether
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecMainStartTimestamp&lt;/code&gt; moves. If it does not, your change is on disk and not
in the process.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restartTriggers&lt;/code&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hermes-agent&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/users/hermes/default.nix&lt;/code&gt;
(covering vpn and public) and inline in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/giga/default.nix&lt;/code&gt;, in the
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd.services.unbound.reloadIfChanged = true&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/default.nix&lt;/code&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/giga/default.nix&lt;/code&gt; — reload instead of restart, to keep the resolver
cache.&lt;/li&gt;
  &lt;li&gt;The Caddy note about new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tls&lt;/code&gt; file pairs in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/default.nix&lt;/code&gt;, which
explains why “reload” is not always enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For how a deploy is verified on the host after a green run, see
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">VictoriaLogs does not de-duplicate</title>
    <id>https://ymrtech.com/notes/victorialogs-does-not-deduplicate/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/victorialogs-does-not-deduplicate/"/>
    <published>2026-10-07T00:00:00-04:00</published>
    <updated>2026-10-07T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">VictoriaLogs keeps every line it is given, so a retrying agent or a restart loop doubles your history — de-duplication is the caller&apos;s job, not the store&apos;s.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="logging"/>
    
    <category term="victorialogs"/>
    
    <category term="vmagent"/>
    
    <category term="systemd"/>
    
    <content type="html">&lt;p&gt;VictoriaLogs is a log store. That sounds like a description, but it is a design
constraint with consequences, and the fleet’s own operations notes record the
exact one: VictoriaLogs &lt;strong&gt;does not de-duplicate&lt;/strong&gt; (the upstream item is
VictoriaMetrics#5080, cited as open). So anything that hands it the same line
twice gets two lines back.&lt;/p&gt;

&lt;p&gt;That is not a bug report. It is the behaviour a store should have. The point is
that two ordinary design facts become duplicated history if you do not account
for them.&lt;/p&gt;

&lt;h2 id=&quot;a-retrying-agent-re-sends-a-batch&quot;&gt;A retrying agent re-sends a batch&lt;/h2&gt;

&lt;p&gt;The file collector here is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vlagent&lt;/code&gt;, configured in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/monitoring/vlagent.nix&lt;/code&gt;. It writes to a disk buffer and keeps a
checkpoint:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;-remoteWrite.tmpDataPath=/var/lib/vlagent
-fileCollector.checkpointsPath=/var/lib/vlagent/file-checkpoints.json
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is a correct, durable design — the unit even restarts on failure with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Restart = &quot;on-failure&quot;&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RestartSec = &quot;5s&quot;&lt;/code&gt;. But a batch that was written
to VictoriaLogs and then &lt;em&gt;not acknowledged&lt;/em&gt; — the connection dropped, the
process restarted mid-flight — is re-sent from the checkpoint. Because the store
does not de-duplicate, that is not an idempotent replay. It is a second copy of
the same lines.&lt;/p&gt;

&lt;h2 id=&quot;a-restart-loop-multiplies-every-startup-line&quot;&gt;A restart loop multiplies every startup line&lt;/h2&gt;

&lt;p&gt;Every unit that logs on start logs again on every restart. A service that
crash-loops therefore multiplies its own startup output, and the fleet’s own
notes record the shape of it: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;preStart&lt;/code&gt; that touches a spool file without the
directory existing loops on “Permission denied”, and the unfixed loop floods
VictoriaLogs with &lt;strong&gt;thousands of “Failed to start” entries, dominating
error-level log counts&lt;/strong&gt;. The store is faithfully recording a system that is
genuinely failing to start thousands of times. The store is not the thing that
is wrong.&lt;/p&gt;

&lt;h2 id=&quot;the-shutdown-window&quot;&gt;The shutdown window&lt;/h2&gt;

&lt;p&gt;The third fact is subtler: a service can log so heavily while stopping that it
dominates a time window, and a stop that is &lt;em&gt;held open&lt;/em&gt; by a stalled log upload
keeps the process alive to log more. This fleet hit exactly that with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd-journal-upload&lt;/code&gt;, whose POST had no timeout and could block the unit for
the whole 90 s stop cap. Bounding the transfer changed the shape of the
shutdown: the notes record the measured improvement on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;giga&lt;/code&gt; host —
&lt;strong&gt;90 s → 11 s (stop cap only) → 6 s&lt;/strong&gt; once &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NetworkTimeoutSec&lt;/code&gt; was set, with the
shutdown window’s tail complete in the store (847 on-disk lines, at least 2000
in VictoriaLogs).&lt;/p&gt;

&lt;p&gt;The same note records the cost of &lt;em&gt;not&lt;/em&gt; bounding it: because the store does not
de-duplicate, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SIGKILL&lt;/code&gt;ed stop can re-ingest up to one state-save interval,
measured at roughly one duplicate line. Small — and exactly the kind of number
that stays invisible until it is not.&lt;/p&gt;

&lt;h2 id=&quot;rules&quot;&gt;Rules&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;“Is this line cheap to store?” belongs in the logging decision.&lt;/strong&gt; A line
written once per loop iteration is not the same as a line written once per
state change.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Prefer one line per state change&lt;/strong&gt; over one per iteration. Log the
transition, not the tick.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Remember the replay is not free.&lt;/strong&gt; A disk-buffered retry is durable; it is
not idempotent.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;When something looks slow, check ingest rate and per-stream volume.&lt;/strong&gt; A
restart loop and a replayed batch both show up as volume, not as an error
message.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Ask what you are actually shipping.&lt;/strong&gt; The question is not whether the store
is correct — it is whether the volume arriving is the volume you intended to
ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/monitoring/vlagent.nix&lt;/code&gt; in the
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt; for the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tmpDataPath&lt;/code&gt; buffer, the checkpoint path and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Restart&lt;/code&gt; policy.&lt;/li&gt;
  &lt;li&gt;VictoriaMetrics#5080, the de-duplication item the fleet’s notes cite as open.&lt;/li&gt;
  &lt;li&gt;The measured shutdown figures and the one-duplicate-line cost, in the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;victorialogs-vmagent&lt;/code&gt; operations notes (the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd-journal-upload&lt;/code&gt;
section), alongside the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd-journal-upload.service: State &apos;stop-sigterm&apos;
timed out. Killing.&lt;/code&gt; line that starts the diagnosis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same argument as &lt;a href=&quot;/logging/&quot;&gt;the logging posture page&lt;/a&gt;:
what you ship decides what is in the store, and the store keeps what it is given.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Proportional weights are not caps</title>
    <id>https://ymrtech.com/notes/proportional-weights-are-not-caps/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/proportional-weights-are-not-caps/"/>
    <published>2026-10-06T00:00:00-04:00</published>
    <updated>2026-10-06T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">CPUWeight and IOWeight are shares of contention, not ceilings: a weight of 10 beside 100 gets a tenth of whatever is contended, and nothing when idle.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="systemd"/>
    
    <category term="cgroups"/>
    
    <category term="performance"/>
    
    <category term="scheduling"/>
    
    <content type="html">&lt;p&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUWeight&lt;/code&gt; looks like a way to promise a service some CPU. It reads like an
allocation: give the important thing a big number, give the backup a small one,
and the important thing is protected. That reading is wrong, and the module
header in this fleet’s config says so in its own words — the weights
“are not caps — on an idle box the background unit still gets everything.”&lt;/p&gt;

&lt;p&gt;The file is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/service-priority.nix&lt;/code&gt;. Its header is worth
reading before the code.&lt;/p&gt;

&lt;h2 id=&quot;a-weight-only-decides-who-wins-a-tie&quot;&gt;A weight only decides who wins a tie&lt;/h2&gt;

&lt;p&gt;cgroup v2 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUWeight&lt;/code&gt; is a &lt;em&gt;relative&lt;/em&gt; weight, defaulting to 100, and it is
consulted only when two cgroups want the same resource at the same moment. One
unit at weight 1000 beside a unit at weight 10 means the first gets a hundred
times the second’s share &lt;em&gt;of whatever is contended&lt;/em&gt;. It does not mean the first
gets a hundred CPUs, or even one. On an idle machine the low-weight unit runs as
fast as it likes, because nothing is competing with it. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IOWeight&lt;/code&gt; is the same
idea one layer down, for the block layer.&lt;/p&gt;

&lt;p&gt;This fleet sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUWeight = 1000&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IOWeight = 1000&lt;/code&gt; on the client-facing
units, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10&lt;/code&gt; on the build and backup units, and the header is
explicit about why that is not a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUQuota&lt;/code&gt;: on an idle box “the background unit
still gets everything, so backups/builds do not slow down for nothing.” That is
a deliberate trade — fairness, not protection.&lt;/p&gt;

&lt;h2 id=&quot;what-the-numbers-were-measured&quot;&gt;What the numbers were, measured&lt;/h2&gt;

&lt;p&gt;The header records a real measurement from 2026-09-19 on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public&lt;/code&gt; host, a
1-vCPU edge: a fleet restic run pushed a portal fetch from &lt;strong&gt;0.25 s
time-to-first-byte to 1.60 s&lt;/strong&gt;, and from 1.41 MB/s to 221 KB/s on the &lt;em&gt;same&lt;/em&gt;
URL, while a CI build held CPU pressure at roughly 58%. A 6x latency regression
with no configuration error anywhere. A neighbour got busy, and the proportional
share did exactly what it says on the tin.&lt;/p&gt;

&lt;p&gt;The method matters more than the numbers: drive load on one unit, then measure
the latency of one known request on the other, on the same host and the same
URL. A weight setting is not verified by reading it. It is verified by measuring
both sides of the contention.&lt;/p&gt;

&lt;h2 id=&quot;the-load-you-contained-may-not-be-in-the-cgroup-you-contained&quot;&gt;The load you contained may not be in the cgroup you contained&lt;/h2&gt;

&lt;p&gt;The second trap is quieter, and it is the one that survives a correct weight
table. Builds run in the &lt;strong&gt;nix-daemon’s&lt;/strong&gt; cgroup, not the runner’s: the runner
only spawns the nix &lt;em&gt;client&lt;/em&gt;, and the daemon forks the builders. So putting a CI
runner in its own slice and weighting that slice does not bound the builds it
triggers — they are the daemon’s children, weighed wherever the daemon is. The
module weights &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-daemon.serviceConfig&lt;/code&gt; too, and at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;50&lt;/code&gt; rather than &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10&lt;/code&gt;,
because the deploy jobs also go through that daemon and a deploy must not crawl.&lt;/p&gt;

&lt;p&gt;That is the general lesson: before you claim a workload is isolated, ask which
cgroup it actually lands in.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;systemd-cgls                 &lt;span class=&quot;c&quot;&gt;# the tree, with units under their cgroups&lt;/span&gt;
systemctl status &amp;lt;unit&amp;gt;      &lt;span class=&quot;c&quot;&gt;# the unit&apos;s own slice and cgroup&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;rules&quot;&gt;Rules&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Weights are for fairness.&lt;/strong&gt; Use them to decide who wins when siblings
compete, and expect the loser to take its full share when nothing else wants
the resource.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Quotas and limits are for protection.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUQuota&lt;/code&gt; is a ceiling. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemoryMax&lt;/code&gt;
is a hard cap. If a latency-sensitive service must not be slowed by a greedy
neighbour, that is the tool.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verify the cgroup, not the intent.&lt;/strong&gt; Confirm with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd-cgls&lt;/code&gt; or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemctl status&lt;/code&gt; that the work lands where the weight is before you claim
isolation.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Know what cgroup weighting cannot reach.&lt;/strong&gt; The same header names two
limits: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IOSchedulingClass&lt;/code&gt; is &lt;em&gt;inert&lt;/em&gt; on these hosts — the only block device
is a rotational volume on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mq-deadline&lt;/code&gt;, which does not honour io priorities —
and on the wireguard host the client dataplane is kernel-side (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wg-crypt&lt;/code&gt;
kworkers, NAT forwarding, softirq) and cannot be cgroup-weighted at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/service-priority.nix&lt;/code&gt; in the
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt;: the
“not caps” paragraph, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUWeight = 1000&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10&lt;/code&gt; split, and the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-daemon&lt;/code&gt; weight of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;50&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemoryHigh = &quot;768M&quot;&lt;/code&gt; on the background units and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemoryLow = &quot;128M&quot;&lt;/code&gt; on
the foreground ones — on these hosts the actual disk-latency protection comes
from the memory side, not from the weights.&lt;/li&gt;
  &lt;li&gt;On a host: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd-cgls&lt;/code&gt; to see which cgroup a build actually lands in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For how a deploy is verified on the host rather than assumed from a green run,
see &lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A speed test that runs inside the machine it measures</title>
    <id>https://ymrtech.com/notes/speed-test-inside-the-machine/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/speed-test-inside-the-machine/"/>
    <published>2026-10-05T00:00:00-04:00</published>
    <updated>2026-10-05T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">An endpoint measures the path it traverses. Run the test on the host you are measuring and the number is dominated by loopback and CPU, not by the network.</summary>
    
    <category term="networking"/>
    
    
    <category term="vpn"/>
    
    <category term="speedtest"/>
    
    <category term="hmac"/>
    
    <category term="measurement"/>
    
    <content type="html">&lt;p&gt;A throughput number is a claim about a path, and a path has two ends. If both
ends are the same host, or if one end is a one-vCPU machine that is busy running
everything else, the number is mostly a measurement of the machine. It will
happily report a fast link over a bad one. The header of
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/api/speedtest.go&lt;/code&gt; puts it bluntly:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;// a speed test running inside the machine it is measuring is not a speed test
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;where-the-bytes-actually-travel&quot;&gt;Where the bytes actually travel&lt;/h2&gt;

&lt;p&gt;The browser in the tunnel moves bytes to and from the &lt;strong&gt;tunnel endpoint&lt;/strong&gt; — the
host every client tunnel terminates on, served by this same binary under its
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clientsv probe&lt;/code&gt; role. The downlink is streamed from there; the uplink is
counted there.&lt;/p&gt;

&lt;p&gt;The portal is deliberately the other half. It serves the page, owns the
session, mints the short-lived proof the far end requires, and writes the one
result row. It &lt;strong&gt;does not move the bytes&lt;/strong&gt;. That distinction is the design:
the edge host is one vCPU that also serves the site, the admin console,
monitoring and CI, and its load moves the throughput measured through it by a
factor of several.&lt;/p&gt;

&lt;p&gt;The far end is a deliberately minimal role of the same binary: no database, no
session, no cookies, no templates, no history, no write path. Two routes carry
the load, and both are pure copy work:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;block := speedBlock()                          // 1 MiB, incompressible, repeated

n, err := io.Copy(io.Discard, io.LimitReader(r.Body, cap+1))   // the uplink
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every byte the test moves is a byte the host copied: a one-vCPU host can
saturate on the copy before the link does, so the ceiling is often the host,
not the network. A finished test stores one row — two rates, the byte counts, the latency, the
device and tier, and the time.&lt;/p&gt;

&lt;h2 id=&quot;auth-without-a-usage-log&quot;&gt;Auth without a usage log&lt;/h2&gt;

&lt;p&gt;An endpoint anyone can hit has to be authenticated, and the easy way is to give
it an account and log it. A session cookie is host-only, so a browser cannot
carry the portal’s session to another name — the far end needs its own
credential, and it gets a short-lived bearer token instead of an account.&lt;/p&gt;

&lt;p&gt;The portal mints it and the far end verifies it &lt;strong&gt;statelessly&lt;/strong&gt;: HMAC-SHA256
over an expiry and a nonce, keyed by a secret both hosts read from the same
sops file. The nonce makes two tokens minted in the same second different, so a
leaked one is not replayable. The
shipped TTL is 3 minutes, the far end accepts nothing with an expiry more than
15 minutes out, and 30 seconds of clock skew is absorbed.&lt;/p&gt;

&lt;p&gt;There is no access log entry for any of it. The far end writes nothing, the
fleet’s caddy skips the probe paths on the endpoint, and on the edge
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/speedtest/*&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log_skip&lt;/code&gt;ped next to a comment explaining why. One
request line carries a byte count and a timestamp, and a few dozen of them are
a per-test throughput measurement tied to an address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The induced load is itself a denial-of-service surface&lt;/strong&gt; on a small edge host,
which is why the credential is short-lived and per-request rather than a
long-lived key, and why there is a concurrency ceiling of 12 requests in
flight. Over the ceiling the far end answers 503 and the page falls back to
measuring at the portal — a stalling client degrades the test instead of
parking the host. Stealing a token buys bandwidth, not access, and it buys it
for minutes.&lt;/p&gt;

&lt;h2 id=&quot;what-a-throughput-figure-needs-next-to-it&quot;&gt;What a throughput figure needs next to it&lt;/h2&gt;

&lt;p&gt;Never present one number on its own. Say, at minimum:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;What the two endpoints were.&lt;/strong&gt; “Client to tunnel endpoint” and “client to
the site’s front door” are different paths and different numbers.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What the transfer size was.&lt;/strong&gt; The test streams a 1 MiB block repeatedly and
caps a single request at 512 MiB per direction; a short transfer measures
ramp, not throughput.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What the host was doing.&lt;/strong&gt; On a one-vCPU edge, a backup job or a deploy
moves the number by a factor of several.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The headers&lt;/strong&gt;, in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/api/speedtest.go&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/api/probe.go&lt;/code&gt;:
where the bytes travel, what is stored, and the sentence about the machine
measuring itself.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The token.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MintProbeToken&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;probeTokenValid&lt;/code&gt; show the HMAC over
expiry and nonce, the 3-minute TTL and the 15-minute ceiling; the key comes
from the same sops file both hosts read.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The absence of a log line.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/speedtest/*&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;log_skip&lt;/code&gt;ped in the
public vhost’s caddy config, with the reasoning written next to it, and the
probe paths are skipped on the endpoint.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The one row.&lt;/strong&gt; Its fields and its five-row retention are in the same file
as the code that writes them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A short-lived token protects against replay, against correlation by value, and
bounds the blast radius of a leak — it expires in minutes and grants a bounded
transfer to one far end. It does not protect the host from a concurrent test
saturating its CPU, which the concurrency ceiling caps as a degradation
control rather than a defence.&lt;/p&gt;

&lt;p&gt;What is recorded and what is not is on
&lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;; the policy it belongs to is
&lt;a href=&quot;/notes/what-no-logs-cannot-promise/&quot;&gt;what “no logs” can and cannot
promise&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Shape it, don&apos;t meter it: fairness with no per-peer record</title>
    <id>https://ymrtech.com/notes/shape-it-dont-meter-it/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/shape-it-dont-meter-it/"/>
    <published>2026-10-04T00:00:00-04:00</published>
    <updated>2026-10-04T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">HTB scheduling decides from queue state, not from an accounting problem. That is how peers get fair shares of a link while no per-peer usage record is kept.</summary>
    
    <category term="networking"/>
    
    
    <category term="vpn"/>
    
    <category term="traffic-shaping"/>
    
    <category term="htb"/>
    
    <category term="privacy"/>
    
    <content type="html">&lt;p&gt;A saturated link raises two questions that look like one: unequal service, and
who caused it. The second is a logging question, and here the answer is no.
There is no per-peer traffic record, so a saturated tier cannot be diagnosed
after the fact and per-byte billing is off the table.&lt;/p&gt;

&lt;p&gt;That is not the end of fairness, because fairness comes out of the data path.
The file that says so starts with a comment:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;// WHY A SHAPER AND NOT METERING: the no-log posture forbids keeping a per-peer
// traffic record ... Fairness therefore has to come out of the data path itself
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/tiershape/tiershape.go&lt;/code&gt; in the clientsv repository.&lt;/p&gt;

&lt;h2 id=&quot;scheduling-is-a-decision-not-a-ledger&quot;&gt;Scheduling is a decision, not a ledger&lt;/h2&gt;

&lt;p&gt;HTB builds a hierarchy of classes, and a parent’s rate is shared among its
children by weight. The property that matters is that &lt;strong&gt;HTB is
work-conserving&lt;/strong&gt;: an idle member is never dequeued to, so its share goes to
the busy ones per packet. Three members saturating and one idle, and the busy
ones get the idle member’s share — because the scheduler picked the next packet
off whichever queue had one.&lt;/p&gt;

&lt;p&gt;There is no counter to persist, which is exactly why the design survives a
no-log posture. And you &lt;strong&gt;cannot report per-peer bytes from it&lt;/strong&gt;. Asked to
graph who used what, the answer is that this design does not know, and
cannot be made to know without adding the record it exists to avoid.&lt;/p&gt;

&lt;h2 id=&quot;one-tree-on-one-device&quot;&gt;One tree on one device&lt;/h2&gt;

&lt;p&gt;The cap a customer buys is per &lt;strong&gt;account&lt;/strong&gt;, and an account may hold several
devices on several tunnels, so a tree per tunnel cannot express it. The tree
therefore lives on a single shaper device, an IFB, and both tunnels are
funnelled into it.&lt;/p&gt;

&lt;p&gt;Linux can only shape traffic it can &lt;strong&gt;enqueue&lt;/strong&gt;, which is why an egress qdisc
polices egress and a tunnel’s ingress — the client-to-server, upload direction
— used to be unshaped. Redirecting both directions into a virtual device puts
them under one trunk, one account class and one device class:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Rate       string   // guaranteed rate of one account
Ceil       string   // the cap one account&apos;s devices share
RootCeil   string   // trunk ceiling (the instance&apos;s capacity)
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Classes are keyed on the peer’s assigned tunnel address — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/32&lt;/code&gt;, matched as
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src_ip&lt;/code&gt; for upload and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst_ip&lt;/code&gt; for download — because when traffic leaves a
tunnel the peer’s public key is long gone and the tunnel address is the only
stable identity left. Classid tokens are base 16 with a stride of ten, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;120&lt;/code&gt;
is account 12’s class and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;123&lt;/code&gt; its third device.&lt;/p&gt;

&lt;h2 id=&quot;what-a-rate-is-not&quot;&gt;What a rate is not&lt;/h2&gt;

&lt;p&gt;A guaranteed rate is a &lt;strong&gt;long-run average enforced by the scheduler&lt;/strong&gt;, not a
hard per-instant cap. An account may burst well above its guaranteed rate while
the trunk is idle, and it can always use its guarantee even when the trunk is
busy. The code pins the quantum to 1500 bytes for exactly this reason: left at
tc’s default, a 1.8 Gbit trunk gets a multi-megabyte quantum — larger than the
queue itself — and the scheduler then interleaves accounts per burst instead of
per packet. Hitting the ceiling does not produce a row; it produces a queue
that drains slower.&lt;/p&gt;

&lt;h2 id=&quot;checking-the-discipline-on-a-live-host&quot;&gt;Checking the discipline on a live host&lt;/h2&gt;

&lt;p&gt;The answer has to come from the kernel, not the config repository. Read it in
this order:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc qdisc show&lt;/code&gt; — all devices, no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev&lt;/code&gt; filter. A real tree shows up here
even when the interface you asked about looks bare.&lt;/li&gt;
  &lt;li&gt;Per interface: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc qdisc show dev $I&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc class show dev $I&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc filter show dev $I&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ip -br link show type ifb&lt;/code&gt;, then dump the tree on that device too — the
mirror target is where a cross-interface policy actually lives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WireGuard and AmneziaWG devices default to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;noqueue&lt;/code&gt;, so an empty &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc class&lt;/code&gt;
output on a tunnel is &lt;strong&gt;not&lt;/strong&gt; proof of “unshaped”: a cross-interface policy
shows there as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clsact&lt;/code&gt; qdisc plus filters that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mirred ... redirect&lt;/code&gt; into
the IFB. Grep the filter output for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mirred&lt;/code&gt; before concluding. And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tc -j&lt;/code&gt;
reports HTB rates in &lt;strong&gt;bytes&lt;/strong&gt; per second — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rate 100mbit&lt;/code&gt; reads back as
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;rate&quot;:12500000&lt;/code&gt; — so comparing against a bit value makes every class look
drifted forever.&lt;/p&gt;

&lt;p&gt;The reconciler reads the kernel back after applying: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-dry-run&lt;/code&gt; prints the
commands and changes nothing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-check&lt;/code&gt; reports drift as exit code 3 so a timer
can alarm without mutating the tree.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The tree, on the host.&lt;/strong&gt; The commands above, run on the endpoint that
carries the tunnels.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The header comment in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;internal/tiershape/tiershape.go&lt;/code&gt;&lt;/strong&gt;, which states the
no-log constraint and the account-versus-tier reasoning the tree follows.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The defaults&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;100mbit&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1gbit&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.8gbit&lt;/code&gt;, quantum &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1500&lt;/code&gt; — as
constants in the same package.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What a no-log design cannot measure:&lt;/strong&gt; per-peer usage, in any direction, at
any resolution. The aggregate counters disclosed at &lt;a href=&quot;/logging/&quot;&gt;what we
log&lt;/a&gt; are the only volume data that exists,
and they are attributable to an account, not to a peer’s activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the policy side — what the service records and what it refuses to — see
&lt;a href=&quot;/notes/what-no-logs-cannot-promise/&quot;&gt;what “no logs” can and cannot
promise&lt;/a&gt;, and for
the rest of the network posture, &lt;a href=&quot;/notes/three-dns-postures-one-tunnel/&quot;&gt;three DNS postures, one
tunnel&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The secret that reached the transcript</title>
    <id>https://ymrtech.com/notes/secret-that-reached-the-transcript/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/secret-that-reached-the-transcript/"/>
    <published>2026-10-03T00:00:00-04:00</published>
    <updated>2026-10-03T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Ciphertext in git, plaintext at activation. The sops store protects the repository, so the leak this fleet actually had was a running system quoted into a log.</summary>
    
    <category term="security"/>
    
    
    <category term="secrets"/>
    
    <category term="sops"/>
    
    <category term="automation"/>
    
    <category term="redaction"/>
    
    <content type="html">&lt;p&gt;The interesting question about a secret store is not whether it is encrypted.
It is &lt;strong&gt;where the plaintext has to exist once the system is running&lt;/strong&gt;, because
that is where it gets out.&lt;/p&gt;

&lt;p&gt;This fleet’s secrets live as ciphertext in version control — as they did in a
public repository until 2026-09-23, when the single store was split by recipient
set and moved to a private one. That part is sound either way, and it is worth
understanding exactly why — the failure mode that actually happened here is not
in the repository at all.&lt;/p&gt;

&lt;h2 id=&quot;ciphertext-is-the-easy-part&quot;&gt;Ciphertext is the easy part&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt; lists recipients by age public key and maps a path pattern to the
set allowed to decrypt. Until 2026-09-23 there was one store, in the public
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt;, and one
catch-all rule over it:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;# until 2026-09-23, in the public repository
creation_rules:
  - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
    key_groups:
    - age:
      - *admin_truva
      - *hermes_agent
      - *giga
      - *mail
      - *public
      - *vpn
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That single rule is the defect the split fixed: one file, six recipient
identities, so mail’s host key opened the portal’s tokens and the VPN’s private
keys. The store is now one file per set, and a file’s &lt;strong&gt;name is its recipient
set&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/shared-mail-vpn.yaml&lt;/code&gt; is readable by mail, vpn, the owner and
the operator, and by nobody else — in the &lt;em&gt;private&lt;/em&gt; configuration repository.
What the public &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt; still carries is each host’s public identity
anchor, which is the complete answer to “who can read a given secret”, plus one
deliberately bogus &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/demo.yaml&lt;/code&gt; kept as a worked example.&lt;/p&gt;

&lt;p&gt;Every value in the store is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ENC[AES256_GCM,...]&lt;/code&gt;. The ciphertext is in git,
nothing is exposed, and the file’s name states who can open it. Each host
generated its own age key &lt;strong&gt;on the host&lt;/strong&gt;, wrote the private half to
/var/lib/sops-nix/keys.txt (root:root 0600), and published only the public
half. The headers record two rotations: a full one on 2026-09-17, and the
2026-09-23 re-key whose identities superseded those. The second matters because
the &lt;em&gt;escrow ciphertext&lt;/em&gt; of the 2026-09-17 identities sits in the public
repository’s history to this day — which is why the fleet was re-keyed rather
than merely re-encrypted. No retired identity can recover a current value from
any revision.&lt;/p&gt;

&lt;p&gt;That is the positive control, and it is the part people miss: because the
recipients are enumerated and the store is in git, you can say precisely &lt;strong&gt;who
could read a given secret&lt;/strong&gt;, and rotating is a reviewable commit rather than an
incident channel.&lt;/p&gt;

&lt;h2 id=&quot;but-the-plaintext-has-to-be-rendered-somewhere&quot;&gt;But the plaintext has to be rendered somewhere&lt;/h2&gt;

&lt;p&gt;sops-nix decrypts at activation, not at build. The plaintext then lands in the
running system in the shapes a unit needs: an EnvironmentFile, or a templated
file under a runtime path. Both are ordinary configuration:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;environmentFiles = [ config.sops.secrets.hermes-env.path ];   # hosts/giga/default.nix
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;So the plaintext is reachable by anything that can read that rendered path, or
that dumps a unit’s resolved environment. A diagnostic that prints a unit’s
resolved configuration prints secrets. &lt;strong&gt;A transcript that captures such a
command pushes those values off-host&lt;/strong&gt;, and a transcript has no expiry.&lt;/p&gt;

&lt;h2 id=&quot;the-scar-this-policy-has&quot;&gt;The scar this policy has&lt;/h2&gt;

&lt;p&gt;Two records are kept in the repository itself, which is where they belong.&lt;/p&gt;

&lt;p&gt;The first is in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt;: the pre-rotation identity list is described as
the four ssh-host-key-derived keys, and the header says, in a parenthetical,
that &lt;strong&gt;mail’s had leaked into a transcript&lt;/strong&gt;. A host key used as an age
identity is a host key that can be extracted from wherever it was printed.&lt;/p&gt;

&lt;p&gt;The second is an assertion. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops.age.generateKey = true&lt;/code&gt; makes
sops-install-secrets generate an age key at activation and &lt;strong&gt;print its secret
half to stdout&lt;/strong&gt; — and the deploy jobs run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nixos-rebuild switch&lt;/code&gt; with stdout
and stderr going straight into the job log, which any reader of the repository
can open. The guard in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/global/sops-generate-key-guard.nix&lt;/code&gt; fails
evaluation rather than publish a key in production: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assertion = cfg.allow ||
!(config.sops.age.generateKey or false);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The scale of the transcript risk is not hypothetical either. A bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d&lt;/code&gt; on
a store file returns every &lt;strong&gt;value&lt;/strong&gt;. When that happened here the single store
held &lt;strong&gt;48 live secrets&lt;/strong&gt;: one tool result, off-host to the model provider, with
nothing left but a full rotation. The operational rule is the narrow one: use
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d secrets/&amp;lt;set&amp;gt;.yaml | cut -d: -f1&lt;/code&gt; when you want key &lt;em&gt;names&lt;/em&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-net-under-the-rule&quot;&gt;The net under the rule&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/hermes-plugin-secret-guard&lt;/code&gt; exists because Hermes’ own redaction
catches credential-&lt;em&gt;shaped&lt;/em&gt; strings, and a sops value is opaque — a passphrase,
a PreSharedKey, a plain hex token — so it passes through untouched. The plugin
has two hooks: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;transform_tool_result&lt;/code&gt; rewrites every known value in any tool
result to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[REDACTED:&amp;lt;name&amp;gt;]&lt;/code&gt; before it reaches the model, the session store or
the logs, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pre_tool_call&lt;/code&gt; notices commands that print raw values (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cat /run/secrets/...&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;age -d&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;printenv ...TOKEN&lt;/code&gt;). Its own README states
that the incident it prevents &lt;strong&gt;has happened twice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A guard is a net, not a policy. The policy is four rules:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Read the encrypted source and the metadata — key names, recipients, rotation
date — rather than the rendered value.&lt;/li&gt;
  &lt;li&gt;Identify a secret by &lt;strong&gt;name and version&lt;/strong&gt;, never by value.&lt;/li&gt;
  &lt;li&gt;Treat transcripts, issue bodies and CI logs as publication surfaces, because
they are.&lt;/li&gt;
  &lt;li&gt;Rotate anything that was exposed. “It was only a transcript” is not a threat
model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cat .sops.yaml&lt;/code&gt;&lt;/strong&gt; in the public nix-config repository: every host’s public
identity anchor, and the header that dates the split and the rotations. The
store’s own rules are in the private repository; there, a file’s name is its
recipient set.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The rendered paths on a host.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemctl show hermes-agent -p
Environment&lt;/code&gt;, and the file behind any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.sops.secrets.*.path&lt;/code&gt; — this is
what a diagnostic prints, and the reason the quoting rule exists.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The assertion.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/global/sops-generate-key-guard.nix&lt;/code&gt; fails a
build if a host is configured to generate its own key at activation, with the
log-reading reasoning in the failure message.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The key names only.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d secrets/&amp;lt;set&amp;gt;.yaml | cut -d: -f1&lt;/code&gt; returns
identifiers, which is the whole of what a review needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same argument as &lt;a href=&quot;/notes/what-no-logs-cannot-promise/&quot;&gt;what “no logs” can and cannot
promise&lt;/a&gt;: a
policy is meaningful when you can name the surface it protects. For how the
store is wired into the hosts, see &lt;a href=&quot;/infra/&quot;&gt;how the stack is
built&lt;/a&gt;, and for the automated check over
the repository, &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The auto-merge that dropped a security gate</title>
    <id>https://ymrtech.com/notes/auto-merge-that-dropped-a-security-gate/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/auto-merge-that-dropped-a-security-gate/"/>
    <published>2026-10-02T00:00:00-04:00</published>
    <updated>2026-10-02T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Auto-merge decides &apos;the change was correct earlier&apos;, not now. If the base moves, what lands is a merge result no job evaluated — and a conflict can drop a gate.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="git"/>
    
    <category term="merge"/>
    
    <category term="review"/>
    
    <content type="html">&lt;p&gt;Auto-merge does not automate the judgement “this change is correct”. It automates a
different sentence: “this change was correct at some earlier moment”. Those are the
same statement only while the base branch holds still — and on a repo taking several
pull requests an hour, it does not.&lt;/p&gt;

&lt;h2 id=&quot;what-actually-landed&quot;&gt;What actually landed&lt;/h2&gt;

&lt;p&gt;The failure on this fleet was not a bad merge in any dramatic sense. It was textual.
Git can auto-merge a workflow file “successfully” and still drop a job’s steps,
because a merge is textual and the two edits were structurally different. Measured
here: a rebase over a concurrent pull request silently removed a
signature-verification gate from the &lt;em&gt;one&lt;/em&gt; job that actually publishes — a green
pipeline with a missing security gate.&lt;/p&gt;

&lt;p&gt;Nothing about that outcome is visible in the result. The file has no conflict markers,
the pull request is green, and the job that lost its gate still runs — without the
thing that was protecting it.&lt;/p&gt;

&lt;p&gt;A conflict resolution is worse, not better: it is &lt;strong&gt;new code that no test has seen&lt;/strong&gt;.
The merge commit is not the commit that passed CI, and most CI systems do not build the
merge result unless you explicitly ask them to. So the artifact that reaches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; is
frequently a revision nobody evaluated.&lt;/p&gt;

&lt;h2 id=&quot;required-checks-are-evaluated-against-a-base-that-can-move&quot;&gt;Required checks are evaluated against a base that can move&lt;/h2&gt;

&lt;p&gt;The second half of the problem is in the forge. Required status checks are evaluated
against the base branch as it was when they ran — and Forgejo does not re-run
pull-request workflows when the base branch moves: it runs them on pull-request events
(opened, synchronized, reopened). Nothing else.&lt;/p&gt;

&lt;p&gt;A pull request that was green against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; an hour ago therefore still shows green
after several other pull requests landed underneath it. The check is a true statement
about an earlier &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;. The workflow that exists because of this is explicit about
it:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Forgejo does NOT re-run PR workflows when the BASE branch moves: it only runs them
on PR events (opened / synchronize / reopened).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That comment sits at the top of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/pr-base-guard.yml&lt;/code&gt; in the public
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech/nix-config&lt;/code&gt; repository. The job inside re-checks the pull request head
against the &lt;em&gt;current&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin/main&lt;/code&gt; using the same three-way merge the merge itself
would perform — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git merge-tree --write-tree&lt;/code&gt; — so a real conflict fails in seconds
instead of surfacing at merge time as a 405, or after the merge as a post-merge deploy
rollback.&lt;/p&gt;

&lt;p&gt;Three details are worth stealing:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A required context is the workflow name, a literal ` / `, the job name, then the
event in parens.&lt;/strong&gt; The suffix is part of the string, so renaming a workflow or a job
silently breaks enforcement — the required context then never reports.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Only unfiltered jobs may be required.&lt;/strong&gt; A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt;-filtered or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if:&lt;/code&gt;-skipped job
never posts a passing status, so requiring one blocks every merge forever. Exactly
two jobs on this repo qualify and are required: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Secret Scan / gitleaks scan
(pull_request)&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PR base guard / Base drift / conflict check (pull_request)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;It deliberately does not fail a behind-but-clean branch.&lt;/strong&gt; With several pull
requests landing per hour, requiring branch-equals-main would invalidate every open
pull request on each merge. It covers the real risk — a &lt;em&gt;conflicting&lt;/em&gt; branch — and
warns when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; moved and touched the same files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;make-the-safe-path-the-cheap-one&quot;&gt;Make the safe path the cheap one&lt;/h2&gt;

&lt;p&gt;The counter-argument is that auto-merge exists because the manual step is
boring, and a boring step that is merely required gets skipped. Discipline is not a
control. So the fix is not “be more careful” — it is to leave auto-merge in place and
put the verification where the work already happens.&lt;/p&gt;

&lt;p&gt;The invariant is short: &lt;strong&gt;the gate must be re-evaluated against the merge RESULT, and
a human reads every conflict resolution.&lt;/strong&gt; The base guard is that idea made cheap —
one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[gate]&lt;/code&gt;-labelled job, no nix, seconds of runtime, unfiltered, so it runs on every
pull request.&lt;/p&gt;

&lt;p&gt;The other half is refusing to trust the merge call — this chain learned it the same
way:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;FATAL: PR #$LOCK_PR reported merged but main&apos;s flake.lock does not pin clientsv@$MYSHA
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The comment above it records why: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;200&lt;/code&gt; from the merge endpoint &lt;em&gt;without&lt;/em&gt; the merge
applying was reported as success, because the wait that followed matched a
pre-existing deploy of the old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; head. The lock never reached &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; and nothing
noticed. The remedy has the same shape as everywhere else on this site — assert the
effect, not the status code.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Whether your forge re-runs checks when the base branch updates.&lt;/strong&gt; Most do not, and
the consequence is that “green” is a claim about a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; that may have moved. Test
it rather than assuming: push a second pull request that changes a file the first one
touched, and see whether the first one’s checks re-run.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether a recent merge commit differs from the commit CI actually tested.&lt;/strong&gt; If the
revision that landed on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; is not the head that went green, no check saw what
you shipped.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether a conflict was resolved by a person.&lt;/strong&gt; A resolution is code; it needs the
same reading as any other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same argument as a green deploy that deployed nothing: the check is about
a particular revision of a particular tree, and
&lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;that deploy&lt;/a&gt;
had computed its scope from a single commit’s diff. For the gate that verifies a
change after it lands — the health check and the automatic rollback on the host
itself — see &lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">When the tool shows you ***, never patch from the read</title>
    <id>https://ymrtech.com/notes/never-patch-from-a-masked-read/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/never-patch-from-a-masked-read/"/>
    <published>2026-10-01T00:00:00-04:00</published>
    <updated>2026-10-01T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A masked value and a truncated read are renditions, not files. An edit built from one writes the rendition in: a placeholder, or a silent drop past the cutoff.</summary>
    
    <category term="delivery"/>
    
    
    <category term="automation"/>
    
    <category term="editing"/>
    
    <category term="truncation"/>
    
    <category term="safety"/>
    
    <content type="html">&lt;p&gt;A display is not a file. That sounds obvious until you notice most automation never
sees the file — it sees a rendering of it, and writes &lt;em&gt;that&lt;/em&gt; back.&lt;/p&gt;

&lt;p&gt;Two things make a rendering lie without ever being broken. One is masking, applied on
display. The other is truncation, applied to every tool that pipes command output
into a summary. Both leave the underlying file intact. Both produce an edit that is
wrong the moment you trust them.&lt;/p&gt;

&lt;h2 id=&quot;masking-replaces-the-value-on-the-way-out-not-in-the-file&quot;&gt;Masking replaces the value on the way out, not in the file&lt;/h2&gt;

&lt;p&gt;The fleet runs a secret guard whose whole job is to keep live values out of a
transcript. Its central hook, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;transform_tool_result&lt;/code&gt;, replaces every known value in
&lt;strong&gt;any&lt;/strong&gt; tool result with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[REDACTED:…]&lt;/code&gt; marker naming the key, before that result
reaches the model, the session store or the logs.&lt;/p&gt;

&lt;p&gt;The file on disk still contains the real value. The transcript contains a
placeholder. Build an edit from the transcript — “replace the value I saw with the
new one” — and the value you saw &lt;em&gt;is&lt;/em&gt; the placeholder, so the placeholder is what
gets written. The secret is now a broken literal in a secrets store, and nothing
failed.&lt;/p&gt;

&lt;p&gt;The same shape shows up in CI logs. This fleet’s runner honours &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;::add-mask::&lt;/code&gt; —
probe run 1647 printed the literal as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;***&lt;/code&gt; — and the deploy workflow pipes every
switch, rollback and journal command through a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mask()&lt;/code&gt; filter that rewrites
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AGE-SECRET-KEY-…&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[redacted-age-key]&lt;/code&gt;. So &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;***&lt;/code&gt; is not a hypothetical; it is a
rendering this infrastructure produces, in two different places, on purpose.&lt;/p&gt;

&lt;p&gt;Why the guard exists sets the stakes. An agent once ran &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d
secrets/secrets.yaml&lt;/code&gt; “just to list the key names”, the command returned every
&lt;strong&gt;value&lt;/strong&gt;, and all 48 live fleet secrets were in the conversation — already sent
off-host. Nothing undoes that, and the only remedy left was rotating everything.
Built-in redaction does not catch it either: it matches credential-shaped strings
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sk-…&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AKIA…&lt;/code&gt;, high-entropy blobs), while sops values are opaque passphrases,
pre-shared keys, PEM bodies and plain hex.&lt;/p&gt;

&lt;p&gt;Making the transcript safe is the guard’s job. It is not a licence to edit from the
transcript, because the transcript is the thing that was falsified.&lt;/p&gt;

&lt;h2 id=&quot;truncation-has-the-same-failure-with-no-mask-involved&quot;&gt;Truncation has the same failure with no mask involved&lt;/h2&gt;

&lt;p&gt;Command output is auto-truncated with the full text saved to a file, and large reads
return a continuation offset instead of the rest. The tool tells you — there is a
footer saying so. That footer is also the easiest thing in the output to scroll past.&lt;/p&gt;

&lt;p&gt;An edit keyed on a truncated read has the same defect as one keyed on a masked value,
one step further on: a replace-all against the portion you could see silently drops
everything past the cutoff. Nothing errors. You replaced every occurrence you were
shown.&lt;/p&gt;

&lt;h2 id=&quot;the-procedure-that-avoids-both&quot;&gt;The procedure that avoids both&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Re-read the exact region immediately before editing.&lt;/strong&gt; Not the file you read
five minutes ago and not the summary of it — the current lines, fresh.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Key the edit on unique surrounding context, never on the value.&lt;/strong&gt; Anchor on the
neighbouring key name, the line above, the structure around it. The value is the
one part of a secret’s entry you were never meant to hold; the key name is public.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Change a secret by name reference inside the encrypted store&lt;/strong&gt;, not by replacing
a literal. This is where sops helps, and where the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.sops.yaml&lt;/code&gt; creation rules
decide the outcome by path. Since the store was split one file per recipient set
there is no catch-all rule: each store file is matched by its own
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;path_regex: secrets/&amp;lt;set&amp;gt;\.yaml$&lt;/code&gt;, alongside one rule for the
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/age-escrow/*.age&lt;/code&gt; identities. Encrypt a temp file outside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/&lt;/code&gt; and
no rule matches: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops --encrypt&lt;/code&gt; produces a &lt;strong&gt;plaintext&lt;/strong&gt; file with no SOPS
metadata, throws no error, and exits 0.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verify the encryption, not the command.&lt;/strong&gt; After any edit the key must read back
as ciphertext — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ENC[&lt;/code&gt; — not as a plaintext value. The reliable shape is decrypt
into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/&lt;/code&gt;, edit, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops --encrypt --in-place&lt;/code&gt; on a file still inside
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/&lt;/code&gt;, verify, then replace.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Inventory by name only.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sops -d secrets/&amp;lt;set&amp;gt;.yaml | grep -oE
&apos;^[a-zA-Z0-9_.-]+:&apos;&lt;/code&gt; prints the matched part, which is the key name — values never
reach output. To answer “did this change / is it non-empty”, compare digests and
counts (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha256sum&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep -c&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wc -c&lt;/code&gt;), never values.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;the-review-habit-that-catches-it-after-the-fact&quot;&gt;The review habit that catches it after the fact&lt;/h2&gt;

&lt;p&gt;Any diff containing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;***&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;...&lt;/code&gt;, or a run of dots in a value position was built from
a rendering, and should be rejected on sight. So should a diff where a secrets file’s
value position has lost its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ENC[&lt;/code&gt; envelope. Both are cheap to check now and
impossible to check later.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Whether your tooling masks on read.&lt;/strong&gt; If a value arrives as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;***&lt;/code&gt; or a bracketed
name, that tool is telling you the file was not what you received.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether the file on disk still holds the real value.&lt;/strong&gt; Read the name, not the
value: a count or a digest comparison.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether a recent diff touched a secrets file&lt;/strong&gt; and contains a placeholder. That
is a one-line search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layer underneath — where the encrypted store lives, who the recipients are, and
how a host decrypts at activation — is on
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;. The same discipline
runs through the audit work: a claim you cannot inspect is a claim about intentions,
which is why &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; publishes signed
artifacts rather than a status.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The token that cannot see the other repository</title>
    <id>https://ymrtech.com/notes/token-that-cannot-see-the-other-repo/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/token-that-cannot-see-the-other-repo/"/>
    <published>2026-09-30T00:00:00-04:00</published>
    <updated>2026-09-30T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A token scoped to one repo answers 404 for another, the same as for a repo that does not exist. How a cross-repo step exits zero and changes nothing at all.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="netrc"/>
    
    <category term="cross-repo"/>
    
    <content type="html">&lt;p&gt;A cross-repository step that does nothing is rarely loud. It usually fails as an
authentication symptom wearing a not-found costume, and on this fleet there is a
specific token that cannot see the other repository — by design, because it is
scoped to one repo.&lt;/p&gt;

&lt;h2 id=&quot;the-step-that-has-to-reach-across&quot;&gt;The step that has to reach across&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech/nix-config-private&lt;/code&gt; deploys by bumping its own input inside the &lt;strong&gt;public&lt;/strong&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ymrtech/nix-config&lt;/code&gt; repository’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.lock&lt;/code&gt;. Pushing to the private repo alone
changes nothing on any host until that lock moves. So the private repo’s CD chain
has to write a branch and open a pull request in a repository it does not own — and
the workflow token cannot do it. The workflow says so in its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;env&lt;/code&gt; block:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;NIXCFG&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ymrtech/nix-config&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;API&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://git.ymrtech.com/api/v1&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# github.token is repo-scoped (cannot write nix-config); use the runner&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# user&apos;s ~/.netrc (hermes PAT, rendered on every runner host by the public&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# repo&apos;s modules/clientsv/nix-netrc.nix).&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Two facts follow, and both are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A repo-scoped token answers 404 for a repository outside its scope.&lt;/strong&gt; Not 403.
The API returns the same status it would for a repository that does not exist, so
“you are not allowed” and “there is nothing there” are indistinguishable in the log.
This is not one endpoint’s quirk: on the same forge, the same netrc token produces
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config -&amp;gt; 200 (&quot;private&quot;:false)&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config-private -&amp;gt; 404 (denied)&lt;/code&gt;.
Private-repo run pages and job logs behave the same way — token auth covers the
public repo’s pages only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;netrc matches by host.&lt;/strong&gt; The credential file on this runner has exactly one
entry: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;machine git.ymrtech.com&lt;/code&gt;. A lookup with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl -n&lt;/code&gt; only finds credentials
when the URL’s hostname matches, which is why the CI comment is explicit about it —
curl’s netrc lookup matches the HOSTNAME, so you always call the public hostname the
netrc knows, never an internal address for the same service.&lt;/p&gt;

&lt;h2 id=&quot;why-it-can-exit-zero-and-change-nothing&quot;&gt;Why it can exit zero and change nothing&lt;/h2&gt;

&lt;p&gt;Together those two give you a script with a working read path and a dead write
path — the worst combination to debug, because the first half looks like proof that
the credentials are fine.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Public repositories need no read auth at all.&lt;/strong&gt; Resolving &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;’s head, listing
open pull requests: all of it succeeds with no token, or with a token that has no
permission over that repo. Every step &lt;em&gt;before&lt;/em&gt; the write looks healthy.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Then the write 404s.&lt;/strong&gt; The step that creates the branch or the pull request
fails at the one operation that needs scope — and the message is a not-found.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; exits 0 on an HTTP error unless you ask it not to.&lt;/strong&gt; Without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-f&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt;
hands the error document to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jq&lt;/code&gt;, and a pipeline that merely checks the command
ran can conclude that it did. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;404 page not found&lt;/code&gt; body is valid input to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jq&lt;/code&gt;
expression that then yields &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt; reads as “no result”, not “no
permission”.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the step runs, prints something plausible, exits zero, and changes nothing. The
only symptom is the absence of an effect.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-is-to-assert-the-effect&quot;&gt;The fix is to assert the effect&lt;/h2&gt;

&lt;p&gt;Not to widen the token. A token that can write to everything removes the symptom and
keeps the bug — the step was never verified, it just stopped failing. Scope the token
to what the chain needs, then check the result directly.&lt;/p&gt;

&lt;p&gt;This chain does, because it learned the hard way. Its merge step re-reads the
artifact after merging and compares it against the exact revision this run pushed:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;FATAL: PR #$LOCK_PR reported merged but main&apos;s flake.lock does not pin clientsv@$MYSHA
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The comment above it records the lesson: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;200&lt;/code&gt; from the merge endpoint without the
merge applying was reported as success, because the wait that followed matched a
pre-existing deploy of the &lt;em&gt;old&lt;/em&gt; main head — the lock never reached main and nothing
noticed. A status code from the command that claims to have done the work is not the
work. The pull-request check follows the same rule: rather than trusting the POST
that created it, it lists open pull requests and matches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.head.ref&lt;/code&gt; against the
per-revision branch name it just pushed.&lt;/p&gt;

&lt;p&gt;The general form: &lt;strong&gt;assert that the pull request exists at the expected head
revision&lt;/strong&gt;, or that the file at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ref=main&lt;/code&gt; contains the value you set, or that the
branch resolves. Something that can only be true if the effect happened.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Your netrc’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;machine&lt;/code&gt; lines.&lt;/strong&gt; Every hostname you call must appear, spelled the
way you call it. A missing entry is not an error; it is silence.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether your log can tell the two 404s apart.&lt;/strong&gt; It cannot, from the status
alone. Read the body, and treat any cross-repo step as unverified until you have
checked the artifact.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Whether the step asserts its effect,&lt;/strong&gt; or merely reports its exit code. Find the
write step and ask what verifies it afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same class of problem as a green deploy that deployed nothing: a status
is an input to a decision, not the decision. That post is
&lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;here&lt;/a&gt;; the
layer underneath — the hosts, the runner, and the health gate that verifies a deploy
after the fact — is on &lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A job that waits must not hold the slot it is waiting for</title>
    <id>https://ymrtech.com/notes/job-that-waits-holding-its-own-slot/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/job-that-waits-holding-its-own-slot/"/>
    <published>2026-09-29T00:00:00-04:00</published>
    <updated>2026-09-29T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">One job per runner daemon: a job that holds its slot while waiting on work that needs a slot deadlocks the queue - and the symptom is a hang, not a failure.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="runners"/>
    
    <category term="deadlock"/>
    
    <content type="html">&lt;p&gt;A failed job announces itself: it goes red, names a step, prints a reason. A hung
job does none of that. It sits in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;running&lt;/code&gt; holding a runner slot, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waiting&lt;/code&gt; jobs
pile up behind it, and nothing in the pipeline says anything is wrong — from the
pipeline’s point of view, work is in progress. What follows is the structural
rule that produces it, and the parts of this fleet’s deploy graph that embody it.&lt;/p&gt;

&lt;h2 id=&quot;one-daemon-one-job&quot;&gt;One daemon, one job&lt;/h2&gt;

&lt;p&gt;The capacity of this fleet is not a policy setting; it is a property of the
runner software. One &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;forgejo-runner&lt;/code&gt; daemon serves one job at a time — that is
the tool’s own default, and nothing in the repo raises it. Grepping the fleet
configuration tells the story plainly:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nb&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-rn&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;capacity\|instances\|runner&apos;&lt;/span&gt; nix-config/modules nix-config/hosts | &lt;span class=&quot;nb&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-40&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There is no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;capacity&lt;/code&gt; setting anywhere in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config/modules&lt;/code&gt; or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-config/hosts&lt;/code&gt;; the only hits are unrelated (an inference-server flag and a
Grafana alert comment). What the configuration sets is
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services.gitea-actions-runner.instances.ymrtech&lt;/code&gt; — one instance per host — plus
the labels it advertises. The labels differ per host; the count never does. One
daemon, one slot.&lt;/p&gt;

&lt;p&gt;The workflow comment says it in as many words. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-rev&lt;/code&gt;’s own header in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt; explains why that job moved off &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ci&lt;/code&gt;: on the
shared &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ci&lt;/code&gt; pool it sat “behind mail’s single capacity-1 slot”, and after a
lock-bump merge it measured &lt;strong&gt;3 min 10 s of pure queueing&lt;/strong&gt; for about two seconds
of git work. A label is a matcher, not capacity.&lt;/p&gt;

&lt;h2 id=&quot;the-graph-and-the-one-edge-that-is-a-real-dependency&quot;&gt;The graph, and the one edge that is a real dependency&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt; fans out into three per-host deploy jobs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-mail&lt;/code&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs-on: [deploy-mail]&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs: [scope, deploy-rev]&lt;/code&gt;,
gated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if: contains(needs.scope.outputs.hosts, &apos;mail&apos;)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs-on: [deploy-vpn]&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs: [scope, deploy-rev]&lt;/code&gt;,
gated on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&apos;vpn&apos;&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs-on: [deploy]&lt;/code&gt;,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs: [scope, deploy-rev, deploy-vpn]&lt;/code&gt;, gated on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&apos;public&apos;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deploy jobs are per-host and independent by design, so a
single-host change does not drag the other hosts’ closures along. There is one
ordering edge, and it is not a preference. Each client DNS tier splits its halves
across vpn and public — primary on vpn, standby on public — so if both jobs ran
concurrently one run could bounce both halves in the same second and take every
client’s resolver down. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt; therefore waits for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt;: a real
dependency earns an edge.&lt;/p&gt;

&lt;p&gt;It also has a price. Each job builds and switches a whole NixOS system, and each
demands a runner of its own, so an ordering edge widens the critical path &lt;em&gt;and&lt;/em&gt;
multiplies the slots the run needs in sequence, not in parallel: the vpn slot
must free before the public slot is asked for. That is fine here because the
dependency is real. Applied loosely — an edge added for tidiness, or to make logs
read better — it buys a longer deploy and a larger pool for nothing.&lt;/p&gt;

&lt;h2 id=&quot;the-rule&quot;&gt;The rule&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;A job must never hold the resource it is waiting for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, for a bounded pool: if a job occupies a slot while polling for work
that also needs a slot, the queue cannot drain. Nothing fails; the waiter waits,
the awaited job queues behind it, and the run reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;running&lt;/code&gt; until the job’s
own timeout expires — the v15 API has no run-cancel endpoint at all, so a stuck
run holds the capacity-1 runner for its whole &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeout-minutes&lt;/code&gt;. This fleet has
hit that shape and written the rule down: a chain job held
a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ci&lt;/code&gt; slot while polling for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt; job of the build check it had triggered,
so it queued behind its own CI. The fix was not a longer timeout — it was to move
the waiter to a lane the awaited job can still reach, and to poll the awaited
job’s per-job commit status (one small call) instead of a multi-megabyte task
listing computed &lt;em&gt;inside&lt;/em&gt; the held slot.&lt;/p&gt;

&lt;p&gt;Two rules follow from the same accounting: a dependency widens the critical path
and multiplies slot demand, so it needs a reason; and because a hung run looks
nothing like a failed one, queue depth is worth watching on its own. A run
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;running&lt;/code&gt; far longer than normal, with other jobs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waiting&lt;/code&gt; behind it, is the
signature. Do not wait for a red mark that will never come.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The slot count.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep -rn &apos;capacity&apos; nix-config/modules nix-config/hosts&lt;/code&gt;
returns no runner capacity setting; each host declares one
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;services.gitea-actions-runner.instances.ymrtech&lt;/code&gt;. A daemon registers the
sops-rendered &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/run/secrets/runner-config-&amp;lt;host&amp;gt;&lt;/code&gt;, not the Nix &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;labels&lt;/code&gt; option
— read the two together or you will trust a label list that is not in force.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A run’s job queue.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python3 /var/lib/hermes/workspace/fjlog.py jobs &amp;lt;runIndex&amp;gt;&lt;/code&gt;
prints its jobs with name, status and duration; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fjlog.py status &amp;lt;sha&amp;gt;&lt;/code&gt; is the
cheaper per-job liveness view. Skipped jobs leave their commit-status context
stuck at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pending&lt;/code&gt; forever, so a green &lt;em&gt;run&lt;/em&gt; is usable while a non-terminal
aggregate is not.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Slot occupancy, per host.&lt;/strong&gt; Every host declares the daemon as
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd.services.&quot;gitea-runner-ymrtech&quot;&lt;/code&gt;, so its own journal on that host —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;journalctl -u gitea-runner-ymrtech&lt;/code&gt; — records what it claimed and when. The
capacity lever, if needed, is act_runner’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[runner] capacity&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The habit.&lt;/strong&gt; For every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs:&lt;/code&gt; edge, ask whether the two jobs could have run
at once. If yes, and the edge exists anyway, it costs a slot and a serialisation
for nothing. The one edge in this graph has its justification in the file: two
halves of a DNS tier must not be bounced in the same second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the wider pool — which labels each host advertises, and why the deploy jobs
stay pinned while the light gate jobs float — see
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">One merge, two runs, the same sha</title>
    <id>https://ymrtech.com/notes/one-merge-two-runs-the-same-sha/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/one-merge-two-runs-the-same-sha/"/>
    <published>2026-09-28T00:00:00-04:00</published>
    <updated>2026-09-28T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">One merge can produce more than one workflow run for one commit, and they queue instead of cancelling - so the latest green run may not have touched the host.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="concurrency"/>
    
    <category term="debugging"/>
    
    <content type="html">&lt;p&gt;One merge. Two runs of the same workflow. Same commit sha. Neither cancelled the
other — the second one waited, then ran to completion. That is the designed
behaviour of three lines at the top of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt; in
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;the nix-config repository&lt;/a&gt;, and
understanding it changes how you read a deploy log.&lt;/p&gt;

&lt;h2 id=&quot;queue-never-cancel&quot;&gt;Queue, never cancel&lt;/h2&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;na&quot;&gt;concurrency&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;nix-deploy-$&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;cancel-in-progress&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Lines 151–153. The workflow’s own comment above them explains why this block
exists at all. Forgejo’s documented default, for a workflow with no top-level
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;concurrency&lt;/code&gt;, is that for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on.push&lt;/code&gt; “whenever a new event triggers a new
workflow invocation, any other invocations of the same workflow will be canceled
automatically”. That default is right for a check — a superseded build is
evaluating a commit nobody will merge — and wrong for a deploy. Cancelling a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nixos-rebuild switch&lt;/code&gt; mid-activation can tear an activation half-way (some units
new, some old) and skip the health gate and automatic rollback that follow it.
The comment records a live instance: runs 2701 and 2712 cancelled about forty
seconds apart while 2701 was mid-switch on vpn, leaving the fleet mixed.&lt;/p&gt;

&lt;p&gt;Declaring &lt;em&gt;any&lt;/em&gt; top-level &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;concurrency&lt;/code&gt; block takes the workflow out of the
automatic-cancellation regime. With a group and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancel-in-progress: false&lt;/code&gt;, a
superseded run queues and completes instead of being killed.&lt;/p&gt;

&lt;p&gt;The consequence for reading logs is direct: a later run in the same group
&lt;strong&gt;waits&lt;/strong&gt; rather than cancelling. Both runs execute, in order, and the last one to
touch a host wins.&lt;/p&gt;

&lt;h2 id=&quot;the-group-is-keyed-on-the-ref&quot;&gt;The group is keyed on the ref&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;group: nix-deploy-$&lt;/code&gt; means a push to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; and a
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workflow_dispatch&lt;/code&gt; on a PR branch are in &lt;em&gt;different&lt;/em&gt; groups and never queue
against each other. Two things follow: a dispatch and a push to the same ref share
one queue, so the dispatch lines up behind the push rather than racing it; and
runs on different refs do not serialise at all, even when they name the same host.&lt;/p&gt;

&lt;p&gt;That second point is why the workflow also carries per-host job groups —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-mail&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt;, each with
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancel-in-progress: false&lt;/code&gt;. As the file notes from Forgejo’s documentation,
“Multiple jobs within a workflow are not affected by the concurrency setting”, so
the job-level groups are what make two &lt;em&gt;runs&lt;/em&gt; exclusive on the same host.&lt;/p&gt;

&lt;h2 id=&quot;the-job-graph-and-one-deliberate-always&quot;&gt;The job graph, and one deliberate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always()&lt;/code&gt;&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Job&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs-on&lt;/code&gt;&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs&lt;/code&gt;&lt;/th&gt;
      &lt;th&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scope&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[gate]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-rev&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[gate]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-mail&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[deploy-mail]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[scope, deploy-rev]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contains(needs.scope.outputs.hosts, &apos;mail&apos;)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[deploy-vpn]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[scope, deploy-rev]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contains(needs.scope.outputs.hosts, &apos;vpn&apos;)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[deploy]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[scope, deploy-rev, deploy-vpn]&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always() &amp;amp;&amp;amp; contains(needs.scope.outputs.hosts, &apos;public&apos;)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Three per-host deploy jobs, one real ordering edge. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt; waits on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt; because the client DNS tiers split their two halves across the two
hosts — primary on vpn, standby on public — and the jobs would otherwise run side
by side and bounce both halves in the same second. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always()&lt;/code&gt; is the subtle
half: without it, a &lt;em&gt;skipped&lt;/em&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt; (vpn simply not in scope) would skip
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt; too, because a skipped job skips everything that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs:&lt;/code&gt; it. So
the public job starts even when the vpn job failed or never ran, and its
pre-flight step re-checks &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;needs.deploy-vpn.result&lt;/code&gt; and refuses to switch if the
primary side is broken. That is a deliberate choice about publishing the edge:
the fallback halves must not be left un-deployed just because the primary tier
was untouched.&lt;/p&gt;

&lt;h2 id=&quot;identify-a-run-by-three-things-not-by-position&quot;&gt;Identify a run by three things, not by position&lt;/h2&gt;

&lt;p&gt;With more than one run able to touch one host, “the most recent run in the UI” is
not an identifier. A run is identified by &lt;strong&gt;workflow + commit sha + trigger
event&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The runs list items carry &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workflow_id&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;commit_sha&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prettyref&lt;/code&gt;,
which is enough to find the run for a specific merge per workflow —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fjlog.py runs --sha &amp;lt;sha&amp;gt; --workflow nix-deploy&lt;/code&gt; filters on exactly those.&lt;/li&gt;
  &lt;li&gt;The trigger event is in the branch field: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prettyref&lt;/code&gt; on a run-list item,
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;head_branch&lt;/code&gt; on a task row. PR-event runs read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#&amp;lt;PR&amp;gt;&lt;/code&gt;; push runs read the
branch name. A filter that matches only one form silently misses the other and
waits out its whole deadline against a run that is already finished.&lt;/li&gt;
  &lt;li&gt;A run’s status is not the state of the host. A push-to-main run can be
cancelled after it already did its work — measured case: a cancelled
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nix-deploy&lt;/code&gt; whose target host had switched seven minutes earlier. Cancelled is
not evidence a deploy did not land, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;success&lt;/code&gt; is not evidence it did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When two runs can touch one host, the generation on the host is evidence and the
badge is not. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nixos-rebuild list-generations&lt;/code&gt; prints &lt;strong&gt;newest first&lt;/strong&gt;, so the
current generation is the head of that list; the run’s own badge can only tell
you what the pipeline intended.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;The block itself: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt;, lines 151–153 —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;group: nix-deploy-$&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cancel-in-progress: false&lt;/code&gt; — plus the
job-level groups at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-mail&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-vpn&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The gates on lines 456, 884 and 1601: the per-host &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contains(...)&lt;/code&gt; conditions,
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always() &amp;amp;&amp;amp;&lt;/code&gt; only on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy-public&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;That a superseded run &lt;em&gt;queues&lt;/em&gt;: dispatch the same workflow twice on one branch
and read the second run — it shows &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waiting&lt;/code&gt; while the first stays &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;running&lt;/code&gt;
to completion.&lt;/li&gt;
  &lt;li&gt;The host, not the run: its current generation and the timestamp on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the scope job these deploys hang off — and why a green run can mean nothing
was in scope at all — see
&lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;the green deploy that deployed nothing&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Forgejo does not re-run your pull request when main moves</title>
    <id>https://ymrtech.com/notes/forgejo-does-not-rerun-your-pr/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/forgejo-does-not-rerun-your-pr/"/>
    <published>2026-09-27T00:00:00-04:00</published>
    <updated>2026-09-27T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A green PR check describes the merge base it ran against, not the current main: Forgejo does not re-run PR workflows when the base branch advances.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="merge"/>
    
    <category term="base-drift"/>
    
    <content type="html">&lt;p&gt;A green check next to a pull request is one of the most over-read marks on a
forge. It does not mean “this change is good to merge”. It means something much
narrower: the commit at the head of this PR, merged with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; that existed
when the run started, passed the gates. The second half of that sentence is the
part that expires.&lt;/p&gt;

&lt;h2 id=&quot;forgejo-only-runs-pr-workflows-on-pr-events&quot;&gt;Forgejo only runs PR workflows on PR events&lt;/h2&gt;

&lt;p&gt;The check that guards every PR in &lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;the nix-config repository&lt;/a&gt;
states the reason in its own header. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/pr-base-guard.yml&lt;/code&gt;,
first comment block:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Forgejo does NOT re-run PR workflows when the BASE branch moves: it only runs
them on PR events (opened / synchronize / reopened).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;synchronize&lt;/code&gt; is an event &lt;em&gt;you&lt;/em&gt; cause by pushing to the branch. A merge landing
on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; is an event on a different branch, so it does not re-trigger your PR’s
workflows. The badge does not go stale, because the forge has no concept of a
check describing an older main — from its side, the check is simply green.&lt;/p&gt;

&lt;h2 id=&quot;what-the-guard-computes&quot;&gt;What the guard computes&lt;/h2&gt;

&lt;p&gt;The guard exists for exactly that gap, and it computes the merge instead of
guessing at it:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;git merge-tree &lt;span class=&quot;nt&quot;&gt;--write-tree&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-messages&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; origin/main &amp;lt;branch&amp;gt;
&lt;span class=&quot;c&quot;&gt;# exit 0 = merges clean&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# exit 1 = real conflict&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# exit &amp;gt;1 = git could not compute the merge at all&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The third code is the one worth reading twice. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;gt;1&lt;/code&gt; means git could not perform
the merge — unrelated histories being the classic case — and the guard fails that
as &lt;em&gt;unmergeable&lt;/em&gt;, with its own message, rather than folding it into “conflicts”.
The remedy it prints is not a rebase: an unmergeable branch has to be recreated
from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin/main&lt;/code&gt; and the change re-applied.&lt;/p&gt;

&lt;p&gt;Three details make the job trustworthy:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It checks out &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$&lt;/code&gt; — the branch head,
deliberately not Forgejo’s merge ref, because a merge ref is computed against a
current main and would hide the drift being measured.&lt;/li&gt;
  &lt;li&gt;It runs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git fetch --quiet origin main&lt;/code&gt; first and checks against that, not a
cached ref.&lt;/li&gt;
  &lt;li&gt;It fails closed on missing tooling: a capability probe merges &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEAD&lt;/code&gt; with
itself, and if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;merge-tree --write-tree&lt;/code&gt; is unavailable — it needs git 2.38;
the fleet runs 2.55 — the job exits 1 rather than reporting a clean merge it
never computed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conflict-is-the-loud-case-clobbering-is-the-quiet-one&quot;&gt;Conflict is the loud case; clobbering is the quiet one&lt;/h2&gt;

&lt;p&gt;A red &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Base drift / conflict check&lt;/code&gt; is the case people expect: rebase, resolve,
push. The guard goes further. When the merge is clean it also compares the files
&lt;em&gt;your&lt;/em&gt; branch changed since the merge base against the files &lt;em&gt;main&lt;/em&gt; changed in
the same window, and emits a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;::warning::&lt;/code&gt; when they overlap. It stays green on
purpose — with several PRs an hour, failing every behind-but-clean branch would
force every open PR to rebase and re-run its per-host closure builds whenever
anything else merges.&lt;/p&gt;

&lt;p&gt;That warning carries the real risk. In a repository where more than one agent
session edits at once, “textually clean” is not “semantically safe”. The guard’s
own failure text says not to merge main into the branch as a shortcut, and to
resolve on the file’s &lt;strong&gt;new&lt;/strong&gt; structure rather than re-instating your copy,
because in this repo a conflict usually means main rewrote that file underneath
you. A merge that applies your hunk to a file someone restructured two merges ago
can land green and quietly undo their change.&lt;/p&gt;

&lt;p&gt;The merge endpoint agrees with the guard. A merge returning &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;405&lt;/code&gt;, or a PR
reading &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mergeable:false&lt;/code&gt;, almost always means the branch is behind &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; — not
that the forge is broken.&lt;/p&gt;

&lt;h2 id=&quot;why-this-is-the-default-hazard-not-an-edge-case&quot;&gt;Why this is the default hazard, not an edge case&lt;/h2&gt;

&lt;p&gt;A shared, fast-moving repository turns base drift from an edge case into the
normal state. When several sessions and several PRs land per hour, a branch cut
twenty minutes ago is already behind, and every merge to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; invalidates the
evidence under every other open PR. The discipline this fleet runs on follows
directly: fetch and rebase onto &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin/main&lt;/code&gt; immediately before every push, and
re-verify the base immediately before merging — the only pre-merge check
that means anything is one computed against the current main.&lt;/p&gt;

&lt;p&gt;So the pre-merge check is not the badge. It is, in the moment before you merge:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;git fetch origin
git merge-tree &lt;span class=&quot;nt&quot;&gt;--write-tree&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--no-messages&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; origin/main &amp;lt;branch&amp;gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and when that returns non-zero: rebase onto the freshly fetched &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin/main&lt;/code&gt;,
re-apply the change onto the file’s new shape rather than restoring your copy,
re-run the gates, then &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git push --force-with-lease&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;The workflow itself: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/pr-base-guard.yml&lt;/code&gt; in nix-config, and
the same job in nix-config-private — workflow name &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PR base guard&lt;/code&gt;, job
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Base drift / conflict check&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs-on: [gate]&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on: pull_request&lt;/code&gt; with no
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; filter (docs-only PRs conflict too, and the build check skips them).&lt;/li&gt;
  &lt;li&gt;The job’s own output. It prints &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;branch head:&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin/main:&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;merge base:&lt;/code&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;branch is N commit(s) behind origin/main&lt;/code&gt; before it judges anything, so
the distance from main is visible rather than inferred.&lt;/li&gt;
  &lt;li&gt;The required-check context, read back from the commit status rather than
guessed: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PR base guard / Base drift / conflict check (pull_request)&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The command, run locally, immediately before you click merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the runner pools and the host graph these jobs run on — and why the guard
floats on the light &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gate&lt;/code&gt; label instead of a host’s single slot — see
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Fail closed: when CI cannot compute the diff, evaluate everything</title>
    <id>https://ymrtech.com/notes/fail-closed-when-ci-cannot-diff/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/fail-closed-when-ci-cannot-diff/"/>
    <published>2026-09-26T00:00:00-04:00</published>
    <updated>2026-09-26T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Scope derived from a git diff must fail closed: a shallow checkout makes the diff fail, and reading that failure as no changes deploys nothing at all.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="scope"/>
    
    <category term="git"/>
    
    <category term="fail-closed"/>
    
    <content type="html">&lt;p&gt;The deploy pipeline decides which hosts a change is about by diffing the
push or the pull request. That decision is an inference, and every inference
needs a defined answer for the case where it cannot be performed. The answer
cannot be “no changes”. A diff that could not be computed and a diff that found
nothing are the same empty string, and only one of them means the work is
unnecessary.&lt;/p&gt;

&lt;h2 id=&quot;the-inference-and-its-precondition&quot;&gt;The inference, and its precondition&lt;/h2&gt;

&lt;p&gt;The scope job’s computation is one line:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;git diff &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;HEAD~1...HEAD&quot;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That command requires at least two commits of history to be present in the
checkout. A fresh Forgejo checkout defaults to a shallow depth of one, so on a
merge the parent commit is simply not in the repository, and the command fails
with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fatal: Invalid symmetric difference expression ...HEAD&lt;/code&gt; rather than
returning an empty list. Both workflows that diff therefore pin &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch-depth: 0&lt;/code&gt;
on their scope job’s checkout: the deploy workflow for pushes, the build-check
workflow for pull requests, where the base commit is older still.&lt;/p&gt;

&lt;p&gt;That pin is not decoration. The failure has been observed: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eval-giga&lt;/code&gt; job’s
checkout had no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch-depth: 0&lt;/code&gt;, so on PR events &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git diff &quot;$BASE...HEAD&quot;&lt;/code&gt; died
with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fatal: Invalid symmetric difference expression ...HEAD&lt;/code&gt; and the job
reported &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;giga in scope: false&lt;/code&gt;. The asymmetry is what made it durable — push
events escaped it (depth 1 still contains &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEAD~1&lt;/code&gt;, so they fell through to the
“everything changed” branch), meaning only pull requests silently skipped giga,
and a skipped host looks exactly like a host with nothing to do.&lt;/p&gt;

&lt;h2 id=&quot;why-the-error-does-not-surface&quot;&gt;Why the error does not surface&lt;/h2&gt;

&lt;p&gt;The scope step is written as an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;elif&lt;/code&gt; chain, and the guard that survives
an uncomputable diff is spelled out in the workflow’s own comment:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Fail CLOSED. Inside an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;elif&lt;/code&gt; condition &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -e&lt;/code&gt; never fires, and the branch
below pipes the diff into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep -q .&lt;/code&gt;: an uncomputable diff (base object
absent, shallow history) therefore fell through to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HOSTS=&quot;&quot;&lt;/code&gt; — a green run
that built and evaluated NOTHING. Scope must widen, never silently vanish.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both halves are the point. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -e&lt;/code&gt; is the reflex answer, and it does nothing
here: a command in a condition is exempt, so a failing diff selects a branch
instead of aborting the run. And the pipeline is deceptive in the other
direction — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grep -q .&lt;/code&gt; exists to say “is there any output”, and against nothing
it says no, which is indistinguishable from “there is nothing to see”.&lt;/p&gt;

&lt;h2 id=&quot;the-fallback-widens-in-both-directions&quot;&gt;The fallback widens, in both directions&lt;/h2&gt;

&lt;p&gt;The evaluator’s fix is the shape to copy:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;k&quot;&gt;elif&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt; git diff &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$BASE&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;...HEAD&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;then
  &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;WARN: cannot diff against &apos;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$BASE&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&apos; - evaluating all deployable hosts&quot;&lt;/span&gt;
  &lt;span class=&quot;nv&quot;&gt;HOSTS&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;mail public vpn&quot;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And the push path carries the equivalent guard, testing the parent commit before
the diff is attempted:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;no previous tip available — deploying all hosts
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Unreadable history scopes to &lt;strong&gt;every&lt;/strong&gt; host, not to none. The cost of the
fallback is real — a wider nix build and a few minutes of runner time — and that
is the correct trade against a silent no-op, because the wide case is visible in
the log and the narrow case is not.&lt;/p&gt;

&lt;h2 id=&quot;explicit-input-beats-inference&quot;&gt;Explicit input beats inference&lt;/h2&gt;

&lt;p&gt;The strongest version of fail-closed in this repository is the one that removes
the inference entirely. A manual dispatch takes no checkout and computes no
diff: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts&lt;/code&gt; input is authoritative, and it is required. An empty value
aborts with&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;hosts input is EMPTY — refusing to deploy (name hosts explicitly: mail vpn public)
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and an unrecognised host name aborts too, rather than being dropped from the
scope. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;giga&lt;/code&gt; fails loudly with a pointer to its own workflow, instead of
quietly deploying nothing. A required input whose empty case is
an error cannot be mistaken for an empty diff — which is why that branch is the
pattern worth copying into any script that currently infers scope, as opposed to
one that is handed scope.&lt;/p&gt;

&lt;h2 id=&quot;two-places-an-empty-result-and-a-failure-look-identical&quot;&gt;Two places an empty result and a failure look identical&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -o pipefail&lt;/code&gt; is present in these steps, and it is necessary but not
sufficient: it makes a pipeline fail when the command on the left fails, and
inside a condition that only changes &lt;em&gt;which branch you take&lt;/em&gt;. It does not abort
the run. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipefail&lt;/code&gt; plus an
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;elif&lt;/code&gt; that widens is a fix; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipefail&lt;/code&gt; alone is decoration.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git diff --quiet&lt;/code&gt; is the second trap. It uses exit code 1 for “there are
differences” and a different non-zero for “I could not compute this at all”, so
a test written as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if git diff --quiet; then ... else ...&lt;/code&gt; — the form used in
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake-update.yml&lt;/code&gt; to decide whether &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.lock&lt;/code&gt; actually changed — cannot
tell “identical” from “cannot diff”. In that direction it errs safely (toward
“changed”), but only by accident. Anywhere the same construct gates work by
testing for &lt;em&gt;no&lt;/em&gt; change, it errs silently toward doing nothing.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Read the declared depth where it is declared.&lt;/strong&gt; The scope job’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;with:&lt;/code&gt;
block is the authority on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch-depth&lt;/code&gt;; do not infer it from the job list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Read the scope line.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scoped hosts: &apos;...&apos;&lt;/code&gt; is the whole decision. If it
prints an empty list when you know you touched a host, the scope step is
broken, not the repository empty. Better: assert it non-empty in the step
whenever work was expected.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Confirm the fallback fired.&lt;/strong&gt; If the log carries
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no previous tip available — deploying all hosts&lt;/code&gt; or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cannot diff against &apos;&amp;lt;base&amp;gt;&apos;&lt;/code&gt;, the run widened on purpose — that is the
design working, not a diff that silently missed your change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scope line is the same one that makes a green run meaningful, which is where
&lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;a green deploy that deployed nothing&lt;/a&gt;
begins. The pipeline itself is documented in
&lt;a href=&quot;/infra/&quot;&gt;the infrastructure breakdown&lt;/a&gt;, and the
review discipline that keeps these claims checkable is in
&lt;a href=&quot;/standards/&quot;&gt;the engineering standards&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A rollback that cannot execute is worse than no rollback</title>
    <id>https://ymrtech.com/notes/rollback-that-cannot-execute/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/rollback-that-cannot-execute/"/>
    <published>2026-09-25T00:00:00-04:00</published>
    <updated>2026-09-25T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">An untested rollback does not fail loudly: a denied sudo reads as empty output, the script finds no previous generation, and the host stays broken.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="rollback"/>
    
    <category term="health-gate"/>
    
    <category term="sudo"/>
    
    <content type="html">&lt;p&gt;A rollback is the code path that runs when something is already wrong. That is
the whole difficulty: it executes in the exact window where you have the least
appetite and the fewest options for debugging it, on a host you have just made
worse. An untested rollback is therefore not a rollback with a caveat. It is a
promise about a command that has never run, attached to a moment when you cannot
check.&lt;/p&gt;

&lt;h2 id=&quot;what-the-gate-does&quot;&gt;What the gate does&lt;/h2&gt;

&lt;p&gt;Each host’s deploy job switches, then runs a health gate. In order: poll the
gated services every second for up to about ten seconds (a fixed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep 40&lt;/code&gt; paid
the full cost on every deploy; a fixed &lt;em&gt;short&lt;/em&gt; sleep rolls back a service that is
merely still &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;activating&lt;/code&gt;, because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemctl is-active&lt;/code&gt; reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;activating&lt;/code&gt; as
not active), check for failed units, require every declared critical service
active, and compare against a pre-switch baseline of long-running enabled
services. Any violation rolls back to the previous generation and ends the step
with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-failure-that-looks-like-a-result&quot;&gt;The failure that looks like a result&lt;/h2&gt;

&lt;p&gt;The runner’s sudo is allow-listed to exactly one command. From the workflow’s own
comment on the rollback step:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The runner is allowlisted for exactly ONE privileged command - nixos-rebuild
(see security.sudo.extraRules on the host). Everything else (sudo readlink,
switch-to-configuration, nix-env) prompts for a password with no TTY, and a
REAL gate violation then leaves the host exactly as broken as the gate found
it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A denied sudo is not a crash. It writes its own line to stderr and exits
non-zero — and if the step captured its stdout in a command substitution, the
substitution is what decides the story. There is no
error to propagate: the assignment succeeded, and its value is empty. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -e&lt;/code&gt;
does not fire, because nothing failed in the sense the shell tracks.&lt;/p&gt;

&lt;p&gt;The observed output was this:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;no previous generation ... cannot roll back
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;with empty generation variables — because the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo readlink&lt;/code&gt; that produced
those numbers had been denied. A permissions failure wearing a “no previous
generation” costume. The host stayed on the generation the gate had just
rejected, the run went red with a misleading cause, and the log said nothing
about a password prompt.&lt;/p&gt;

&lt;h2 id=&quot;the-version-in-the-repository-and-its-guards&quot;&gt;The version in the repository, and its guards&lt;/h2&gt;

&lt;p&gt;The step reads the profile link &lt;strong&gt;without&lt;/strong&gt; sudo — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/nix/var/nix/profiles/system&lt;/code&gt;
is world-readable, and requiring privilege to read it is what emptied the
variables — and then refuses to guess:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nv&quot;&gt;CUR_LINK&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;readlink&lt;/span&gt; /nix/var/nix/profiles/system&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;CURNUM&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%s&apos;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$CUR_LINK&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;sed&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;s|.*system-\([0-9]*\)-link|\1|&apos;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;printf&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;%s&apos;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$CURNUM&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-qE&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;^[0-9]+$&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;then
  &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;!!! cannot read a generation number out of &apos;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$CUR_LINK&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&apos; - refusing to guess a rollback target&quot;&lt;/span&gt;
  &lt;span class=&quot;nv&quot;&gt;CURNUM&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;fi&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Three guards follow, and each one is a failure that was seen before it was
fixed:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Parse, then validate.&lt;/strong&gt; The profile link is &lt;em&gt;relative&lt;/em&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;system-474-link&lt;/code&gt;), so
a pattern expecting a leading &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/&lt;/code&gt; substitutes nothing, the number stays a
non-numeric string, and a numeric filter degrades into a string compare that
matches every link — at which point &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sort -n | tail -1&lt;/code&gt; hands back the
generation the gate just rejected. That happened: the rollback re-activated the
broken generation, twice, and still recorded &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROLLBACK_DONE=1&lt;/code&gt;. A deliberate
gate drill on giga (run 1649) is what exposed it, and the run’s own log did not
distinguish it from a success.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Refuse a target equal to what failed.&lt;/strong&gt; If the “previous” closure resolves to
the closure that just failed, the step clears the target rather than running a
rollback that would change nothing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verify where the profile actually landed.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROLLBACK_DONE=1&lt;/code&gt; is set only when
the profile reads back at the exact closure that was aimed at. Otherwise the log
says &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!!! ROLLBACK DID NOT LAND ON THE PREVIOUS GENERATION&lt;/code&gt; and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HOST NEEDS AN OPERATOR NOW&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;non-interactive-or-it-is-not-a-check&quot;&gt;Non-interactive, or it is not a check&lt;/h2&gt;

&lt;p&gt;The one place the step invokes a privileged command that is &lt;em&gt;not&lt;/em&gt; on the
allowlist is the journal dump for failed units, and it is written to fail
explicitly:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nb&quot;&gt;sudo&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-n&lt;/span&gt; journalctl &lt;span class=&quot;nt&quot;&gt;-u&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$u&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-n&lt;/span&gt; 40 &lt;span class=&quot;nt&quot;&gt;--no-pager&lt;/span&gt; 2&amp;gt;&amp;amp;1 | mask &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;(journal not readable without privilege - read it on the host)&quot;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-n&lt;/code&gt; is the load-bearing flag. Without it, sudo prints its password lecture, and
that lecture is what filled the section on 2026-09-16 while the actual cause
stayed hidden — a wall of text that reads like an error and is not one. The
pattern generalises: invoke an allow-listed (or possibly-denied) sudo with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-n&lt;/code&gt;,
check its exit status, and say plainly which of “not permitted” and “not found”
you are looking at.&lt;/p&gt;

&lt;h2 id=&quot;loud-because-quiet-is-the-expensive-option&quot;&gt;Loud, because quiet is the expensive option&lt;/h2&gt;

&lt;p&gt;The rollback must abort the run and name the host, not absorb the problem.
Failures here are not paged — the webhook and its spool-draining crons were
removed from this pipeline on 2026-09-16 and the operator reads the runs himself
— so the run log &lt;em&gt;is&lt;/em&gt; the alert channel, which is one more reason an ambiguous
line costs hours.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Read the gate step’s own lines&lt;/strong&gt;, not the job summary: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rollback verified: the
profile is on &amp;lt;closure&amp;gt;&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;!!! ROLLBACK DID NOT LAND&lt;/code&gt;, or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no usable previous
generation - CANNOT ROLL BACK&lt;/code&gt;. A rollback claim that does not name the closure
it landed on is not a rollback.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Check every privileged call in the step against the host’s sudo allowlist.&lt;/strong&gt;
On mail and vpn the allowlist is the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nixos-rebuild&lt;/code&gt; binary alone.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Re-test by dispatching a deploy&lt;/strong&gt;, not by trusting a green run — a green run
cannot distinguish a working gate from a disarmed one. The rollback only
executes when something is already wrong, which is the worst place for a
command that has never been executed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mechanics sit in the deploy workflow documented in
&lt;a href=&quot;/infra/&quot;&gt;the infrastructure breakdown&lt;/a&gt;, which is
also where to look for how the fleet’s other gates avoid the same trap.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">A secret rotation that merged green and reached no host</title>
    <id>https://ymrtech.com/notes/secret-rotation-that-reached-no-host/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/secret-rotation-that-reached-no-host/"/>
    <published>2026-09-24T00:00:00-04:00</published>
    <updated>2026-09-24T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A secrets-only merge can produce no workflow run at all: the paths filter decides whether a run exists, the scope job decides which hosts it reaches.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="secrets"/>
    
    <category term="sops"/>
    
    <category term="paths-filter"/>
    
    <content type="html">&lt;p&gt;A credential rotation merges to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;, and the run list stays empty. Not a red
run — no run. There is nothing to misread, because there is nothing there.&lt;/p&gt;

&lt;p&gt;That is a different silent failure from the one where a run goes green and
deploys nothing. The fleet deploy has two independent gates between a merge and
a host, and they answer two different questions.&lt;/p&gt;

&lt;h2 id=&quot;one-filter-decides-whether-a-run-exists-at-all&quot;&gt;One filter decides whether a run exists at all&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NixOS Deploy&lt;/code&gt; is triggered by a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; filter
(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt; in the &lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;  &lt;span class=&quot;na&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;branches&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;paths&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;flake.nix&apos;&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;flake.lock&apos;&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;hosts/**&apos;&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;modules/**&apos;&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;overlays/**&apos;&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;secrets/**&apos;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That list is not scoping. It is creation. A merge touching none of those paths
produces &lt;strong&gt;no workflow run at all&lt;/strong&gt; — and a missing run is not a pending run.
Waiting for it never resolves; there is no job that will eventually appear.&lt;/p&gt;

&lt;p&gt;The comment directly above the block exists because of exactly this, and it is
worth reading in full:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/**&lt;/code&gt; is host-relevant, not docs-like: the sops store is SHARED code
(one file, every host’s defaultSopsFile), and the scope step below maps
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/&lt;/code&gt; to ALL hosts. Without it here, a secrets-only merge is a silent
no-op: no run is created, and even a forced run would scope to zero hosts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two halves of one sentence, and they are the whole post. &lt;em&gt;No run is created&lt;/em&gt; is
the filter. &lt;em&gt;Even a forced run would scope to zero hosts&lt;/em&gt; is the second gate,
and it fails independently — which is why a rotation bug has two chances to be
invisible.&lt;/p&gt;

&lt;h2 id=&quot;the-other-gate-decides-which-hosts&quot;&gt;The other gate decides which hosts&lt;/h2&gt;

&lt;p&gt;Once a run exists, its first job computes which hosts the run is about, and the
whole output of that computation is one line:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;scoped hosts: &apos; public&apos;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The shared-code branch of that computation is an explicit list:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;git diff &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;HEAD~1...HEAD&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--&lt;/span&gt; flake.nix flake.lock modules/ overlays/ hosts/common/ secrets/ | &lt;span class=&quot;nb&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-q&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;.&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A hit there scopes &lt;strong&gt;every&lt;/strong&gt; host, with one measured exception: a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.lock&lt;/code&gt;-only
bump that moves just a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rev&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;narHash&lt;/code&gt; scopes to vpn alone, and the guard for that
path additionally requires that no input was added, removed or renamed. A path
under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/&amp;lt;host&amp;gt;/&lt;/code&gt; scopes just that host.
Anything else scopes nothing, and with an empty scope each host job’s
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if: contains(needs.scope.outputs.hosts, &apos;mail&apos;)&lt;/code&gt; is false, every deploy job is
skipped, and the run concludes green. That mechanism — a green run that deployed
nothing — is the subject of &lt;a href=&quot;/notes/green-deploy-that-deployed-nothing/&quot;&gt;its own post&lt;/a&gt;.
It is worth keeping the three apart:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Failure&lt;/th&gt;
      &lt;th&gt;Where it happens&lt;/th&gt;
      &lt;th&gt;What you see&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;No run&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on: push: paths:&lt;/code&gt; filter&lt;/td&gt;
      &lt;td&gt;no run; a watch that never resolves&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Green run, zero hosts&lt;/td&gt;
      &lt;td&gt;scope job, empty result&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scoped hosts: &apos;&apos;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Green run, wrong hosts&lt;/td&gt;
      &lt;td&gt;scope job, stale enumeration&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scoped hosts:&lt;/code&gt; without the host you changed&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;two-lists-and-they-are-not-the-same-list&quot;&gt;Two lists, and they are not the same list&lt;/h2&gt;

&lt;p&gt;This is the part that makes the class of bug recur. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; filter carries
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/**&lt;/code&gt;; the scope’s shared-code list carries &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/&lt;/code&gt;. That difference
is deliberate and correct — a change to one host’s directory should reach that
host, not the fleet. Each list answers its own question, so copying one into the
other is not a fix, and adding a directory to only one of them is a partial fix
whose remaining half fails silently.&lt;/p&gt;

&lt;p&gt;The rule that follows: when a new top-level directory starts being consumed by
hosts, it needs an entry in &lt;strong&gt;both&lt;/strong&gt; places, and the second entry is the one that
is easy to forget because the first one makes CI look alive. The same class shows
up one level down. The deploy workflow’s filter deliberately names config paths
only — no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/**&lt;/code&gt; — so a workflow-only merge does not
auto-deploy, and a workflow change is validated by a manual dispatch instead. The
build-check workflow’s filter &lt;em&gt;does&lt;/em&gt; include workflows, because it has to be able
to validate its own changes. And a script under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/scripts/**&lt;/code&gt; that a
workflow step calls needs that path in the calling workflow’s filter, or editing
the script runs nothing.&lt;/p&gt;

&lt;h2 id=&quot;the-escape-hatch-and-why-it-is-not-the-fix&quot;&gt;The escape hatch, and why it is not the fix&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workflow_dispatch&lt;/code&gt; is not subject to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paths:&lt;/code&gt; filters at all. A manual dispatch
always runs, and its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts&lt;/code&gt; input is authoritative — that job takes no checkout
and computes no diff, so nothing can scope it down. That makes dispatch a
reliable way to deploy a host on purpose, and it is why the &lt;em&gt;forced run&lt;/em&gt; case in
the comment is still recoverable.&lt;/p&gt;

&lt;p&gt;It is not a fix for a missing filter entry, because the failure mode is
ignorance: the rotation merged, nothing went red, and nobody knows there is work
to do. An escape hatch only helps the person who already suspects.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Reconcile the lists.&lt;/strong&gt; Read the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;on:&lt;/code&gt; block and the scope job’s shared-code
list, then list the directories the hosts actually reference. Every shared one
must appear in both.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Treat a missing run as a result, not a delay.&lt;/strong&gt; If a push touched a
host-relevant file and no run exists a few seconds later, the filter decided
that — it is not “still starting”. Match the diff against the filter before
waiting for anything.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;If you must deploy now, dispatch.&lt;/strong&gt; A manual dispatch bypasses the whole
question, and names its hosts explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same discipline as everywhere else on this site: &lt;a href=&quot;/infra/&quot;&gt;the deploy pipeline&lt;/a&gt;
is only as trustworthy as the claim you can independently re-derive from the
files it runs from.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The green deploy that deployed nothing</title>
    <id>https://ymrtech.com/notes/green-deploy-that-deployed-nothing/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/green-deploy-that-deployed-nothing/"/>
    <published>2026-09-23T00:00:00-04:00</published>
    <updated>2026-09-23T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A green deploy can leave a host on an old generation: scope comes from a single commit&apos;s diff, and an empty scope skips every deploy job while the run still reports success.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="deploy"/>
    
    <category term="scope"/>
    
    <content type="html">&lt;p&gt;A green check next to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NixOS Deploy&lt;/code&gt; is one of the easiest things to
misread on this fleet. It is tempting to read it as &lt;em&gt;the change is on the
hosts now&lt;/em&gt;. What it actually means is narrower and worth stating exactly,
because the gap between those two sentences is where the interesting
failures live.&lt;/p&gt;

&lt;p&gt;The workflow is public: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/nix-deploy.yml&lt;/code&gt; in the
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;nix-config repository&lt;/a&gt;. It
starts by deciding &lt;strong&gt;which hosts the run is about&lt;/strong&gt;, and only then runs a
deploy job per host.&lt;/p&gt;

&lt;h2 id=&quot;green-means-nothing-to-do-not-done&quot;&gt;Green means “nothing to do”, not “done”&lt;/h2&gt;

&lt;p&gt;The first job is called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Compute changed host scope&lt;/code&gt;, and its whole output
is one line in the log:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;scoped hosts: &apos; public&apos;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That line is computed from a single diff:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;git diff &lt;span class=&quot;nt&quot;&gt;--name-only&lt;/span&gt; HEAD~1...HEAD
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;— the &lt;em&gt;last commit only&lt;/em&gt;. A path on the shared list puts &lt;strong&gt;every&lt;/strong&gt; host in
scope; a path under a single host’s directory puts &lt;strong&gt;that&lt;/strong&gt; host in scope;
anything else puts nothing in scope at all.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Changed path&lt;/th&gt;
      &lt;th&gt;Hosts in scope&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.nix&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.lock&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/**&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;overlays/**&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/common/**&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/**&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;every host&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/&amp;lt;host&amp;gt;/**&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;host&amp;gt;&lt;/code&gt; only&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;README, docs, anything else&lt;/td&gt;
      &lt;td&gt;&lt;em&gt;none&lt;/em&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Each host’s deploy job is then gated on its own name:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;contains(needs.scope.outputs.hosts, &apos;mail&apos;)&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With an empty scope, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contains()&lt;/code&gt; is false for every host, so &lt;strong&gt;every
deploy job is skipped&lt;/strong&gt;. A skipped job is not a failure. The scope job
succeeds, the run concludes, and the summary is green. Nothing was
deployed, and nothing was wrong — the pipeline correctly decided there was
nothing to do.&lt;/p&gt;

&lt;p&gt;That is the design working. It becomes a problem in exactly one situation:
when a file that a host genuinely consumes is &lt;strong&gt;not on either list&lt;/strong&gt;. Then
the run is green, the log says &lt;em&gt;no hosts&lt;/em&gt;, and the host keeps running
whatever generation it had before. There is no red mark anywhere, because
from the workflow’s point of view there was no work.&lt;/p&gt;

&lt;h2 id=&quot;the-scope-rules-are-a-list-and-lists-go-stale&quot;&gt;The scope rules are a list, and lists go stale&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising: the scope rule is an enumeration of
path prefixes, and an enumeration is a claim about the future that someone
has to keep true. The comment above the file list in the workflow says it
plainly — the shared paths are the ones that “scope every host”, and
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secrets/**&lt;/code&gt; is on that list because &lt;em&gt;the secrets store is shared code, not
a document&lt;/em&gt;. It was not always on that list. While it was missing, a change
to it was classified as irrelevant, produced an empty scope, and deployed
nothing at all — with a green run.&lt;/p&gt;

&lt;p&gt;So the rule of thumb is not “does CI pass”. It is:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;A green deploy run tells you the scope was computed. Read the scope.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;what-you-can-actually-check&quot;&gt;What you can actually check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The run’s own log.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scoped hosts: &apos;...&apos;&lt;/code&gt; is printed before anything
deploys. If you changed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/mail/**&lt;/code&gt; and that line does not contain
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mail&lt;/code&gt;, the run is telling you it did not deploy mail.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The host, not the run.&lt;/strong&gt; The check that counts is the one on the machine
itself — the generation it is running, and the timestamp on it. A run
that is green and a host that is three days behind are entirely
compatible facts.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The dispatch path, which does not guess.&lt;/strong&gt; A manual dispatch takes an
explicit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts&lt;/code&gt; input, and that input is &lt;em&gt;authoritative&lt;/em&gt;: the job takes
no checkout and computes no diff. An empty or unrecognised value aborts
the run rather than quietly scoping to nothing. When you mean “deploy
this host”, say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same argument as anywhere else on this site where a status is
published: &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; is built from signed artifacts
precisely because a claim you cannot inspect is only a claim about
intentions. A green badge is an input to a decision, not the decision.&lt;/p&gt;

&lt;p&gt;For the layer underneath this one — the hosts, the runner, the health gate
that verifies a deploy after the fact and rolls it back if it fails — see
&lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">1,221 commits and a pipeline that did not exist: three months of the fleet</title>
    <id>https://ymrtech.com/notes/three-months-of-the-fleet/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/three-months-of-the-fleet/"/>
    <published>2026-09-22T00:00:00-04:00</published>
    <updated>2026-09-22T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Three months, four NixOS hosts, 1,221 commits, and a CI pipeline that did not exist in August — plus the week the commit log changed hands for good.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="nixos"/>
    
    <category term="ci-cd"/>
    
    <category term="retrospective"/>
    
    <category term="fleet"/>
    
    <content type="html">&lt;style&gt;
.chart-wrap{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:var(--gap);margin:1.6rem 0 .6rem;overflow-x:auto}
.chart-wrap svg{min-width:640px}
.chart-note{color:var(--muted);font-size:var(--fs-sm);line-height:1.6;margin:0 0 1.8rem}
&lt;/style&gt;

&lt;p&gt;Between 22 June and 22 September 2026 the repositories behind this site took
&lt;strong&gt;1,221 commits&lt;/strong&gt; and &lt;strong&gt;534 merged pull requests&lt;/strong&gt;. At the start of that window
there was no continuous integration at all: a change reached a host when someone
typed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nixos-rebuild switch&lt;/code&gt; and watched the terminal. At the end of it,
thirteen workflow files — roughly 5,700 lines — stand between a commit and a
live host, and every deploy either passes a health gate or rolls itself back.&lt;/p&gt;

&lt;p&gt;This is what those three months produced on the fleet and on the VPN product,
and, in the middle of it, the week the commit log changed hands.&lt;/p&gt;

&lt;p&gt;There is one non-technical reason the pace was possible, and it deserves to be
on the record: I am single and I do not have a life. The fleet has me instead.
Whatever is left over goes to the commit messages.&lt;/p&gt;

&lt;div class=&quot;chart-wrap&quot;&gt;
&lt;svg viewBox=&quot;0 0 940 240&quot; role=&quot;img&quot; aria-label=&quot;Weekly commit counts across the four repositories, split human versus agent. Agent commits are absent until late August 2026, then dominate.&quot; style=&quot;width:100%;height:auto;display:block;font-family:var(--mono)&quot;&gt;
&lt;line x1=&quot;8&quot; y1=&quot;192&quot; x2=&quot;932&quot; y2=&quot;192&quot; style=&quot;stroke:var(--line);stroke-width:1&quot; /&gt;
&lt;line x1=&quot;8&quot; y1=&quot;144.5&quot; x2=&quot;932&quot; y2=&quot;144.5&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:2 4;opacity:.6&quot; /&gt;
&lt;text x=&quot;10&quot; y=&quot;140.5&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;100&lt;/text&gt;
&lt;line x1=&quot;8&quot; y1=&quot;97.0&quot; x2=&quot;932&quot; y2=&quot;97.0&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:2 4;opacity:.6&quot; /&gt;
&lt;text x=&quot;10&quot; y=&quot;93.0&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;200&lt;/text&gt;
&lt;line x1=&quot;8&quot; y1=&quot;49.5&quot; x2=&quot;932&quot; y2=&quot;49.5&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:2 4;opacity:.6&quot; /&gt;
&lt;text x=&quot;10&quot; y=&quot;45.5&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;300&lt;/text&gt;
&lt;rect x=&quot;8.0&quot; y=&quot;188.2&quot; width=&quot;61.8&quot; height=&quot;3.8&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;38.9&quot; y=&quot;183.2&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;8&lt;/text&gt;
&lt;text x=&quot;38.9&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jun 22&lt;/text&gt;
&lt;rect x=&quot;79.8&quot; y=&quot;136.4&quot; width=&quot;61.8&quot; height=&quot;55.6&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;110.8&quot; y=&quot;131.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;117&lt;/text&gt;
&lt;text x=&quot;110.8&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 06&lt;/text&gt;
&lt;rect x=&quot;151.7&quot; y=&quot;164.9&quot; width=&quot;61.8&quot; height=&quot;27.1&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;182.6&quot; y=&quot;159.9&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;57&lt;/text&gt;
&lt;text x=&quot;182.6&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 13&lt;/text&gt;
&lt;rect x=&quot;223.5&quot; y=&quot;180.1&quot; width=&quot;61.8&quot; height=&quot;11.9&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;254.5&quot; y=&quot;175.1&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;25&lt;/text&gt;
&lt;text x=&quot;254.5&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 20&lt;/text&gt;
&lt;rect x=&quot;295.4&quot; y=&quot;165.9&quot; width=&quot;61.8&quot; height=&quot;26.1&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;326.3&quot; y=&quot;160.9&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;55&lt;/text&gt;
&lt;text x=&quot;326.3&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 27&lt;/text&gt;
&lt;rect x=&quot;367.2&quot; y=&quot;184.4&quot; width=&quot;61.8&quot; height=&quot;7.6&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;398.2&quot; y=&quot;179.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;16&lt;/text&gt;
&lt;text x=&quot;398.2&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 03&lt;/text&gt;
&lt;rect x=&quot;439.1&quot; y=&quot;149.7&quot; width=&quot;61.8&quot; height=&quot;42.3&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;470.0&quot; y=&quot;144.7&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;89&lt;/text&gt;
&lt;text x=&quot;470.0&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 10&lt;/text&gt;
&lt;rect x=&quot;510.9&quot; y=&quot;145.0&quot; width=&quot;61.8&quot; height=&quot;47.0&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;text x=&quot;541.8&quot; y=&quot;140.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;99&lt;/text&gt;
&lt;text x=&quot;541.8&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 17&lt;/text&gt;
&lt;rect x=&quot;582.8&quot; y=&quot;168.7&quot; width=&quot;61.8&quot; height=&quot;23.3&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;rect x=&quot;582.8&quot; y=&quot;146.4&quot; width=&quot;61.8&quot; height=&quot;22.3&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;
&lt;text x=&quot;613.7&quot; y=&quot;141.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;96&lt;/text&gt;
&lt;text x=&quot;613.7&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 24&lt;/text&gt;
&lt;rect x=&quot;654.6&quot; y=&quot;189.1&quot; width=&quot;61.8&quot; height=&quot;2.9&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;rect x=&quot;654.6&quot; y=&quot;165.4&quot; width=&quot;61.8&quot; height=&quot;23.8&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;
&lt;text x=&quot;685.5&quot; y=&quot;160.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;56&lt;/text&gt;
&lt;text x=&quot;685.5&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 31&lt;/text&gt;
&lt;rect x=&quot;726.5&quot; y=&quot;189.1&quot; width=&quot;61.8&quot; height=&quot;2.9&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;rect x=&quot;726.5&quot; y=&quot;77.0&quot; width=&quot;61.8&quot; height=&quot;112.1&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;
&lt;text x=&quot;757.4&quot; y=&quot;72.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;242&lt;/text&gt;
&lt;text x=&quot;757.4&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Sep 07&lt;/text&gt;
&lt;rect x=&quot;798.3&quot; y=&quot;191.0&quot; width=&quot;61.8&quot; height=&quot;1.0&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;
&lt;rect x=&quot;798.3&quot; y=&quot;30.0&quot; width=&quot;61.8&quot; height=&quot;161.0&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;
&lt;text x=&quot;829.2&quot; y=&quot;25.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;341&lt;/text&gt;
&lt;text x=&quot;829.2&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Sep 14&lt;/text&gt;
&lt;rect x=&quot;870.2&quot; y=&quot;182.5&quot; width=&quot;61.8&quot; height=&quot;9.5&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;
&lt;text x=&quot;901.1&quot; y=&quot;177.5&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;20&lt;/text&gt;
&lt;text x=&quot;901.1&quot; y=&quot;206.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Sep 21&lt;/text&gt;
&lt;line x1=&quot;577.8&quot; y1=&quot;22&quot; x2=&quot;577.8&quot; y2=&quot;192&quot; style=&quot;stroke:var(--amber);stroke-width:1.5;stroke-dasharray:4 3&quot; /&gt;
&lt;text x=&quot;583.8&quot; y=&quot;18&quot; style=&quot;fill:var(--amber);font-size:10px&quot;&gt;own git user — Aug 29&lt;/text&gt;
&lt;rect x=&quot;736&quot; y=&quot;4&quot; width=&quot;9&quot; height=&quot;9&quot; style=&quot;fill:var(--muted);opacity:.5&quot; /&gt;&lt;text x=&quot;750&quot; y=&quot;12.5&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;human&lt;/text&gt;
&lt;rect x=&quot;808&quot; y=&quot;4&quot; width=&quot;9&quot; height=&quot;9&quot; style=&quot;fill:var(--green);opacity:.85&quot; /&gt;&lt;text x=&quot;822&quot; y=&quot;12.5&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;agent&lt;/text&gt;
&lt;/svg&gt;
&lt;/div&gt;

&lt;p class=&quot;chart-note&quot;&gt;Every non-merge commit across the four repositories, by
ISO week. Bars are split by the identity that authored the commit. The amber
line is 29 August 2026.&lt;/p&gt;

&lt;h2 id=&quot;the-numbers&quot;&gt;The numbers&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt; &lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Non-merge commits, 22 Jun – 22 Sep&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;1,221&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Authored under the agent identity&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;692&lt;/strong&gt; (57%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Pull requests merged&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;534&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Workflow files at the start / end of the window&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;0 / 13&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hosts described by one configuration repository&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The single largest repository, the one that declares the hosts themselves,
carried 950 of those commits and 410 of those merges.&lt;/p&gt;

&lt;h2 id=&quot;what-the-fleet-is&quot;&gt;What the fleet is&lt;/h2&gt;

&lt;p&gt;Four machines — a public edge, a mail host, a management host, and a GPU
workstation — are described in one Nix flake. Nothing on them is configured by
hand: the packages, the systemd units, the firewall, the WireGuard and
AmneziaWG peers, the DNS resolvers, the monitoring stack and the backups are all
evaluated from files in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hosts/&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;modules/&lt;/code&gt;. A host is reproducible from the
repository, and the deploy path is the only path a change takes to get there —
there is no ssh-and-edit step in the workflow, which is exactly what made the
next three months possible at the pace they happened.&lt;/p&gt;

&lt;h2 id=&quot;june-and-july--making-the-network-declarative&quot;&gt;June and July — making the network declarative&lt;/h2&gt;

&lt;p&gt;The early part of the window is infrastructure work that mostly does not show
up in a screenshot: the mesh itself, the WireGuard/AmneziaWG module and its peer
management, DNS resolution, the backup and scrub timers, and the split between
what a host declares publicly and what it keeps. The interesting thing about
this period is what it &lt;em&gt;enabled&lt;/em&gt; rather than what it shipped — once the network
was code, everything after it could be reviewed, reverted and rebuilt.&lt;/p&gt;

&lt;p&gt;On 19 July the agent framework that now does most of the typing was added to
the fleet as a flake input. That is the first commit in the window where
artificial intelligence appears in this repository at all, and at that point it
was infrastructure, not authorship: an input in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flake.lock&lt;/code&gt;, nothing more.&lt;/p&gt;

&lt;h2 id=&quot;august--monitoring-then-a-change-of-hands&quot;&gt;August — monitoring, then a change of hands&lt;/h2&gt;

&lt;p&gt;August is where the fleet became observable. VictoriaMetrics and VictoriaLogs
landed with Grafana over the top, dashboards and alert rules went in as files,
and a status page began reporting from the same stores the dashboards read. Most
of the month’s commit subjects are fixes to that stack: ingest auth, retention,
alert routing, panels that queried the wrong labels. That is what building
observability looks like in a commit log — a long tail of small corrections
until the graphs settle.&lt;/p&gt;

&lt;p&gt;Then the last week of August does something obvious in the chart above. On
&lt;strong&gt;29 August&lt;/strong&gt; the agent got its own git identity, and the commits that identity
signed first are not experiments: the CI itself.&lt;/p&gt;

&lt;h2 id=&quot;the-pipeline-that-did-not-exist&quot;&gt;The pipeline that did not exist&lt;/h2&gt;

&lt;p&gt;At the start of this window there were zero workflow files in the fleet
repository. There is no gentler way to say it than that deploys were manual and
the only test was someone watching.&lt;/p&gt;

&lt;p&gt;Commit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;073ce12&lt;/code&gt; on 29 August changed that in a single step —
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;feat(ci): Forgejo Actions CI/CD — per-host deploy runners, build gate,
alerting&lt;/code&gt; — and added the build gate and the deploy workflow at once. The same
day, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1b0a065&lt;/code&gt; added a secret scan so that a credential cannot reach the branch
in the first place. What followed is a pipeline that grew in public, one
workflow at a time:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Date&lt;/th&gt;
      &lt;th&gt;What landed&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;29 Aug&lt;/td&gt;
      &lt;td&gt;Build gate + per-host deploy runners + gitleaks secret scan&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5 Sep&lt;/td&gt;
      &lt;td&gt;Weekly flake input update, and alerting on every CI failure path&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;16 Sep&lt;/td&gt;
      &lt;td&gt;Pull-request base guard — block on conflicts, warn on base drift&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The deploy workflow is now &lt;strong&gt;2,321 lines&lt;/strong&gt;. It is not long because it is
complicated; it is long because it refuses to guess. Before switching a host it
records a baseline of the long-running enabled units, then re-checks that
baseline — and every critical service — once a second, up to ten times, passing
the moment all of them are active rather than waiting out a fixed sleep, and
rolls the host back if a unit that was running stopped, if a critical service is
not active, or if a unit disappeared that the host did not declare as retired.
Each host gets its own job, so the deploy of one is not the deploy of all.&lt;/p&gt;

&lt;p&gt;Alongside it, the product repositories got their own pipelines: the client
service builds, tests and deploys itself; the private repository runs a secret
scan, a host-scoped evaluation and a lock-bumping deploy chain; and this site
runs a build-and-test gate, a staging deploy, a promote step, and an ephemeral
per-pull-request preview — the same machinery a normal software team would
expect, on hardware the same team owns.&lt;/p&gt;

&lt;p&gt;There is a supply-chain rule in the weekly update job worth naming, because it
is the kind of decision that is easy to make once and hard to keep: every flake
input is frozen to a commit &lt;strong&gt;at least five days old&lt;/strong&gt; before it is proposed.&lt;/p&gt;

&lt;p&gt;That default is not a wall, though. Freezing each input independently can
produce a set that contradicts itself: one input resolves to a snapshot that has
already removed a builder another input in the same set still calls. When a set
like that refuses to eval or build, the window is loosened &lt;strong&gt;one day at a
time&lt;/strong&gt; — five days, then four, then three — re-frozen and re-tested at every
rung, with the absolute-latest fallback held back as a last resort at the floor.
A loosened window is disclosed in the pull request rather than going through
quietly. A job that cannot be talked into absolute latest is a job worth having.&lt;/p&gt;

&lt;h2 id=&quot;september--the-product-layer&quot;&gt;September — the product layer&lt;/h2&gt;

&lt;p&gt;September is where the fleet turned into something customers touch.&lt;/p&gt;

&lt;p&gt;The client-facing service went from an interface to a deployed Go binary,
database and routes, and then grew the parts a VPN product actually needs: a
provisioning path, per-tier configuration, an upload/download fair-share shaper
so no single peer can starve the rest, live per-peer telemetry, and a portal
that ties it together. The WireGuard tiers were split so that the management
plane and the data plane are not the same surface.&lt;/p&gt;

&lt;p&gt;In the same month we published the things that are meant to be read: a logging
policy, a privacy page and terms; an audit page generated at build time from
signed host reports rather than prose; and an information-security management
system whose documents live in the repository, where they can be reviewed like
anything else. A no-log design was implemented at the level of the storage
layer — counters that could be attributed to a single device were moved to a
30-day retention, per-peer read paths were removed from the admin surface, and
mail submission moved inside the mesh so that handoff no longer leaves the
network.&lt;/p&gt;

&lt;p&gt;Backups moved to encrypted off-site object storage with a documented recovery
runbook. A Kanboard instance, a Matrix homeserver, and the monitoring that
watches all of it were deployed the same way everything else is: as files,
through the pipeline, behind the health gate.&lt;/p&gt;

&lt;h2 id=&quot;where-the-machine-drafted-work-started&quot;&gt;Where the machine-drafted work started&lt;/h2&gt;

&lt;p&gt;This is the part of the log that is genuinely unusual, so it is worth being
precise about rather than sentimental.&lt;/p&gt;

&lt;p&gt;The repository does not record which commits were drafted by a model, and no
commit message says so. But commit &lt;em&gt;style&lt;/em&gt; is a fingerprint. From the beginning
of the window until the third week of July, every commit subject is written the
way a person writes when nobody is watching: lowercase, abbreviated, terse.
Then, in the week of 20 July, the shape changes.&lt;/p&gt;

&lt;div class=&quot;chart-wrap&quot;&gt;
&lt;svg viewBox=&quot;0 0 940 240&quot; role=&quot;img&quot; aria-label=&quot;Share of weekly human-authored commits written in the conventional-commit style, a proxy for machine drafting. Zero through July, a first burst in the week of July 26, then sustained at roughly 80 percent from the week of August 10.&quot; style=&quot;width:100%;height:auto;display:block;font-family:var(--mono)&quot;&gt;
&lt;line x1=&quot;34&quot; y1=&quot;188.0&quot; x2=&quot;924&quot; y2=&quot;188.0&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:;opacity:.7&quot; /&gt;
&lt;text x=&quot;28&quot; y=&quot;191.5&quot; text-anchor=&quot;end&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;0%&lt;/text&gt;
&lt;line x1=&quot;34&quot; y1=&quot;108.0&quot; x2=&quot;924&quot; y2=&quot;108.0&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:2 4;opacity:.7&quot; /&gt;
&lt;text x=&quot;28&quot; y=&quot;111.5&quot; text-anchor=&quot;end&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;50%&lt;/text&gt;
&lt;line x1=&quot;34&quot; y1=&quot;28.0&quot; x2=&quot;924&quot; y2=&quot;28.0&quot; style=&quot;stroke:var(--line);stroke-width:1;stroke-dasharray:2 4;opacity:.7&quot; /&gt;
&lt;text x=&quot;28&quot; y=&quot;31.5&quot; text-anchor=&quot;end&quot; style=&quot;fill:var(--muted);font-size:10px&quot;&gt;100%&lt;/text&gt;
&lt;polyline points=&quot;34.0,188.0 161.1,188.0 288.3,98.4 415.4,138.4 542.6,178.4 669.7,61.6 796.9,61.6 924.0,47.2&quot; style=&quot;fill:none;stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;rect x=&quot;655.7&quot; y=&quot;28&quot; width=&quot;268.3&quot; height=&quot;160&quot; style=&quot;fill:var(--cyan);opacity:.06&quot; /&gt;
&lt;text x=&quot;663.7&quot; y=&quot;41&quot; style=&quot;fill:var(--cyan);font-size:10px&quot;&gt;sustained from the week of Aug 10&lt;/text&gt;
&lt;circle cx=&quot;34.0&quot; cy=&quot;188.0&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;34.0&quot; y=&quot;179.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;0%&lt;/text&gt;
&lt;text x=&quot;34.0&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 06&lt;/text&gt;
&lt;text x=&quot;34.0&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=117&lt;/text&gt;
&lt;circle cx=&quot;161.1&quot; cy=&quot;188.0&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;161.1&quot; y=&quot;179.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;0%&lt;/text&gt;
&lt;text x=&quot;161.1&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 13&lt;/text&gt;
&lt;text x=&quot;161.1&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=57&lt;/text&gt;
&lt;circle cx=&quot;288.3&quot; cy=&quot;98.4&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;288.3&quot; y=&quot;89.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;56%&lt;/text&gt;
&lt;text x=&quot;288.3&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 20&lt;/text&gt;
&lt;text x=&quot;288.3&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=25&lt;/text&gt;
&lt;circle cx=&quot;415.4&quot; cy=&quot;138.4&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;415.4&quot; y=&quot;129.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;31%&lt;/text&gt;
&lt;text x=&quot;415.4&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Jul 27&lt;/text&gt;
&lt;text x=&quot;415.4&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=55&lt;/text&gt;
&lt;circle cx=&quot;542.6&quot; cy=&quot;178.4&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;542.6&quot; y=&quot;169.4&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;6%&lt;/text&gt;
&lt;text x=&quot;542.6&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 03&lt;/text&gt;
&lt;text x=&quot;542.6&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=16&lt;/text&gt;
&lt;circle cx=&quot;669.7&quot; cy=&quot;61.6&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;669.7&quot; y=&quot;52.6&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;79%&lt;/text&gt;
&lt;text x=&quot;669.7&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 10&lt;/text&gt;
&lt;text x=&quot;669.7&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=89&lt;/text&gt;
&lt;circle cx=&quot;796.9&quot; cy=&quot;61.6&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;796.9&quot; y=&quot;52.6&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;79%&lt;/text&gt;
&lt;text x=&quot;796.9&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 17&lt;/text&gt;
&lt;text x=&quot;796.9&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=99&lt;/text&gt;
&lt;circle cx=&quot;924.0&quot; cy=&quot;47.2&quot; r=&quot;3.4&quot; style=&quot;fill:var(--bg);stroke:var(--cyan);stroke-width:2&quot; /&gt;
&lt;text x=&quot;924.0&quot; y=&quot;38.2&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--cyan);font-size:9.5px&quot;&gt;88%&lt;/text&gt;
&lt;text x=&quot;924.0&quot; y=&quot;204.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9.5px&quot;&gt;Aug 24&lt;/text&gt;
&lt;text x=&quot;924.0&quot; y=&quot;217.0&quot; text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:9px;opacity:.75&quot;&gt;n=49&lt;/text&gt;
&lt;/svg&gt;
&lt;/div&gt;

&lt;p class=&quot;chart-note&quot;&gt;Share of weekly commits authored under the human&apos;s own
identity whose subject uses the conventional-commit form
(&lt;code&gt;type(scope):&lt;/code&gt;). Weekly commit counts are shown as n=.&lt;/p&gt;

&lt;p&gt;The conventional-commit form appears at &lt;strong&gt;0%&lt;/strong&gt; through July, then jumps in the
week of 20 July — the same week the site’s deploy pipeline and the reverse proxy
were reworked — and settles at roughly 80% from the week of 10 August onward.
That is the tell: the commit &lt;em&gt;message&lt;/em&gt; changed shape weeks before the commit
&lt;em&gt;author&lt;/em&gt; did. Machine-drafted work written under a human identity looks exactly
like that.&lt;/p&gt;

&lt;p&gt;Four dates mark the transition:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;19 July&lt;/strong&gt; — the agent framework arrives as a flake input. Infrastructure,
not authorship.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;26 July&lt;/strong&gt; — the first commits whose subjects use the conventional form
land, under the human’s own git identity. Nobody is announced; the drafting
style simply changes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;10 August&lt;/strong&gt; — conventional-form subjects become the norm rather than a
burst: 79% of that week’s commits, then 79% again the next week, then 88%.
The author is still the human account.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;29 August&lt;/strong&gt; — the agent gets its own git identity. That week the split is
49% agent; the following weeks are 89%, 98%, 99%, 100%.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By September the fleet is being operated by an agent that opens pull requests
against the same gates a person would have to pass, reads the failing job’s log
when CI goes red, pushes a fresh branch, and merges only when the base-guard
check says the branch is still mergeable. The 57% figure at the top of this post
is a three-month average; the current rate is near total.&lt;/p&gt;

&lt;h2 id=&quot;think-zen-garden-not-herd&quot;&gt;Think zen garden, not herd&lt;/h2&gt;

&lt;p&gt;The pipeline gets more of this post than the features do, and the reason is a
background in systems engineering rather than in software. A production line is
not a pile of machines; it is a set of controls, checkpoints and fail-safes
arranged around a flow, designed so that an operator’s attention is spent on the
exceptions and not on the routine. Years of that, plus rather a lot of Factorio
and Satisfactory, leave you unable to look at a deployment process and see
anything other than a line — one that is either balanced and instrumented, or
being held together by hand.&lt;/p&gt;

&lt;p&gt;So the fleet is built as a production line for configuration. Every input is
inspected before it is admitted: flake inputs frozen to at least five days old,
a window that loosens a day at a time rather than jumping to latest, a secret
scan run before a branch can merge. Every unit is tested before it
ships: the build gate, the staging deploy, the ephemeral per-pull-request
preview. Every station has a checkpoint: the units that must be active before
the switch is called good, and the baseline of long-running units. And nothing
moves without a way back: the automatic rollback, the revert, the pinned
revision. None of it is clever. All of it is deliberate.&lt;/p&gt;

&lt;p&gt;The goal was never speed. It was &lt;strong&gt;set and forget&lt;/strong&gt; — a stack this size (four
hosts, a mesh, mail, monitoring, a client service, a portal, an audit pipeline)
should be something you can walk away from, and the difference between a stack
you can walk away from and one you cannot is almost entirely the quality of the
line underneath it. Herding a behemoth by hand, every week, is exhausting and
unreliable. Building the line so that the behemoth manages itself is calm,
uniform and controlled — and it is the reason a three-month window like this one
contains 1,221 commits instead of 1,221 incidents.&lt;/p&gt;

&lt;p&gt;It is worth stating plainly, because the arcs above read like the output of a
team: this is one person and one agent. The breadth is the point. A specialist
goes deep in a narrow seam and can treat the rest of the line as someone else’s
problem; a generalist has to hold the whole thing at once — the mesh, the mail
path, the identity of the commit log, the retention periods, and the pipeline
that ships all of it. That combination is rarer than it ought to be, and it is
what makes a stack of this size tractable at a headcount of one.&lt;/p&gt;

&lt;p&gt;Understanding every level of it — from the kernel up through the mesh, the
services and the delivery line — and running the whole thing alone, without
outside help, is a rarer combination than it should be. It is also one the
industry is famously bad at noticing. That is its loss, not the line’s.&lt;/p&gt;

&lt;h2 id=&quot;what-this-post-does-not-claim&quot;&gt;What this post does not claim&lt;/h2&gt;

&lt;p&gt;It does not claim the model is doing the thinking alone. The architecture, the
decisions about what a VPN product should refuse to do, and the review of every
change are the human’s — and the commit log shows it: the human identity still
appears every week, opening and merging, and the corrections in the log are
frequent enough to be a feature. What changed is who does the typing, and how
much of the work survives the gates.&lt;/p&gt;

&lt;p&gt;It also does not claim that volume is quality. 1,221 commits is a measure of
throughput, not of correctness, and a fleet that could not be rebuilt from its
repository would make the number meaningless. That is why the elastic measure in
this post is not the commit count but the pipeline: thirteen workflows that
decide whether any of those commits is allowed to reach a host.&lt;/p&gt;

&lt;h2 id=&quot;what-you-can-check&quot;&gt;What you can check&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;The configuration repository is public at
&lt;a href=&quot;https://git.ymrtech.com/ymrtech/nix-config&quot;&gt;git.ymrtech.com/ymrtech/nix-config&lt;/a&gt;.
Every number above is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git log&lt;/code&gt; away.&lt;/li&gt;
  &lt;li&gt;The commit that started the pipeline is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;073ce12&lt;/code&gt;; the agent’s first signed
commits are from 29 August.&lt;/li&gt;
  &lt;li&gt;The &lt;a href=&quot;/audit/&quot;&gt;audit page&lt;/a&gt; is generated from signed
host reports and is checked by the build, not by this post.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Three DNS postures, one tunnel</title>
    <id>https://ymrtech.com/notes/three-dns-postures-one-tunnel/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/three-dns-postures-one-tunnel/"/>
    <published>2026-09-22T00:00:00-04:00</published>
    <updated>2026-09-22T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">The three VPN tiers differ in exactly one thing — who resolves your DNS, and what that resolver can see. What each tier actually changes, and what it costs you.</summary>
    
    <category term="networking"/>
    
    
    <category term="vpn"/>
    
    <category term="dns"/>
    
    <category term="wireguard"/>
    
    <category term="privacy"/>
    
    <content type="html">&lt;p&gt;Every VPN sold as “three tiers” is quietly selling a bandwidth ladder: bronze is
slow, silver is fine, gold is fast. Ours is not that. The tunnel is identical at
every tier — same gateway, same shaping, &lt;strong&gt;up to 1 Gbit/s per connection on all
three&lt;/strong&gt; — and the price is the same $10/mo whichever you pick.&lt;/p&gt;

&lt;p&gt;The only thing that changes is your DNS posture. That turns out to be the
interesting part, because DNS names are the most revealing thing a network
carries: they are unencrypted in the common case, they are written down by
default, and they say what you were about to do before you did it.&lt;/p&gt;

&lt;div class=&quot;stats-row&quot;&gt;
  &lt;div class=&quot;stat-box&quot;&gt;
    &lt;span class=&quot;stat-value&quot;&gt;3&lt;/span&gt;
    &lt;span class=&quot;stat-label&quot;&gt;tiers, one price&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;stat-box&quot;&gt;
    &lt;span class=&quot;stat-value&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;stat-label&quot;&gt;gateway, in Montréal&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;stat-box&quot;&gt;
    &lt;span class=&quot;stat-value&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;stat-label&quot;&gt;query logs — on any tier&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2 id=&quot;pipe--the-tunnel-and-nothing-else&quot;&gt;pipe — the tunnel, and nothing else&lt;/h2&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipe&lt;/code&gt; configuration carries &lt;strong&gt;no DNS setting at all&lt;/strong&gt;. Your device keeps the
resolver it already runs, so lookups leave the tunnel and go to your ISP, your
router, your browser’s built-in resolver — whoever was already answering.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;What changes:&lt;/strong&gt; your traffic is tunnelled; your DNS is not.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Who sees your lookups:&lt;/strong&gt; exactly who saw them before you subscribed.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Who does not:&lt;/strong&gt; us. We are not in the path, and we do not learn the names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the shape of it: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipe&lt;/code&gt; is the tier where you hand us the least.
It is also the tier where a browser doing its own DNS-over-HTTPS can silently
bypass your expectations of the tunnel, because the device still owns the
decision.&lt;/p&gt;

&lt;h2 id=&quot;privacy--our-resolver-inside-the-tunnel&quot;&gt;privacy — our resolver, inside the tunnel&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; puts a resolver at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;172.16.40.1&lt;/code&gt; — an address &lt;strong&gt;reachable only from
inside the tunnel&lt;/strong&gt; — and hands you that as your DNS server. Lookups go up the
tunnel to it, and it forwards upstream over DNS-over-TLS, so the last leg is
encrypted too.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;What changes:&lt;/strong&gt; your lookups stop being visible to the network you are on.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Who sees your lookups:&lt;/strong&gt; us, in the sense that our resolver answers them — and
this is the part that matters, so read the next paragraph rather than the bullet.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What we do with them:&lt;/strong&gt; the resolver runs with its &lt;strong&gt;query log disabled and
its statistics disabled&lt;/strong&gt;. No name in a log, no counts to aggregate. That is
stated with the rest of the retention list on &lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;
and re-asserted by the daily signed audit rather than by prose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the tier where “we keep no logs” stops being a claim about our good
intentions and becomes something with machinery behind it: a check re-applies the
setting, sends a real query through the resolver, injects a canary name that
cannot occur by accident, and then requires that &lt;strong&gt;nothing was written&lt;/strong&gt;. If a log
appears, the check fails and a failing report is still signed and published at its
URL before the unit fails — a violation is a published violation, not a quietly
deleted one. The full mechanism, and how to fetch and verify it yourself, is on
&lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;security--the-same-resolver-with-a-filter-in-front&quot;&gt;security — the same resolver, with a filter in front&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; plus &lt;a href=&quot;https://adguard.com/&quot;&gt;AdGuard Home&lt;/a&gt; at
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;172.16.41.1&lt;/code&gt;, with filter lists that block ads, trackers and known-malware
domains before your device ever connects to them.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;What changes:&lt;/strong&gt; a large part of the web’s background noise never resolves.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Who sees your lookups:&lt;/strong&gt; the same answer as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; — the resolver inside
the tunnel, without query logs or statistics.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What it costs you:&lt;/strong&gt; a filter is an opinion. A name on a list is a site that
does not open, and the list is not always right. If a page fails to load and you
cannot explain why, this tier is the first thing to rule out — switch that one
device to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; and try again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;where-the-tier-is-chosen&quot;&gt;Where the tier is chosen&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per device.&lt;/strong&gt; One subscription runs a laptop on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt;, a phone on
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security&lt;/code&gt; and a work machine on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipe&lt;/code&gt;, three devices per account, each with its
own assignment in the portal. You are not choosing a plan; you are choosing how
much of your DNS to hand us, one device at a time.&lt;/p&gt;

&lt;h2 id=&quot;checking-that-it-is-actually-in-force&quot;&gt;Checking that it is actually in force&lt;/h2&gt;

&lt;p&gt;Do not take the tier name on a portal page as evidence.&lt;/p&gt;

&lt;p&gt;The portal includes a DNS leak test that mints a &lt;strong&gt;unique probe name&lt;/strong&gt;, has your
browser look it up, and then checks whether that lookup reached our resolver
inside the tunnel. A name invented for that one test cannot be in any cache and
cannot be in any list, so where it shows up is a real answer — including when the
answer is “it did not arrive here”.&lt;/p&gt;

&lt;p&gt;If you want to check by hand instead: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipe&lt;/code&gt; leaves your configured resolver
alone, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security&lt;/code&gt; point it at a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;172.16.x.x&lt;/code&gt; address that is only
reachable while the tunnel is up. The &lt;a href=&quot;/vpn/technical/&quot;&gt;technical dossier&lt;/a&gt;
documents the resolver mechanics in full.&lt;/p&gt;

&lt;h2 id=&quot;what-no-tier-does&quot;&gt;What no tier does&lt;/h2&gt;

&lt;p&gt;Being straight about the edges:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;It is not a geo-unblocking service.&lt;/strong&gt; One gateway, one exit, no rotating
addresses, no residential proxies. Your traffic exits in Canada, which is a
feature if that is where you want it to exit, and not one if you were hoping to
look like you are elsewhere.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;DNS is not the whole story.&lt;/strong&gt; A site you log in to still knows who you are. A
browser can still be fingerprinted. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security&lt;/code&gt; blocks names, not malware that is
already on your disk. The list of things a tunnel cannot fix is on
&lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;, and it is a real list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pipe&lt;/code&gt; is a legitimate choice.&lt;/strong&gt; If you already run your own resolver and you
trust it more than a provider you have never met, that is the tier built for
you, and it is not a downgrade.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">The audit page is built from signatures, not sentences</title>
    <id>https://ymrtech.com/notes/audit-page-built-from-signatures/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/audit-page-built-from-signatures/"/>
    <published>2026-09-21T00:00:00-04:00</published>
    <updated>2026-09-21T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Every provider publishes a trust page nobody can check. Ours is generated at build time from artifacts the hosts sign themselves — and the build refuses to render numbers no host put its key behind.</summary>
    
    <category term="infrastructure"/>
    
    
    <category term="audit"/>
    
    <category term="ed25519"/>
    
    <category term="ssh-keygen"/>
    
    <category term="nixos"/>
    
    <content type="html">&lt;p&gt;Every VPN provider has a page that says it keeps no logs. The page is prose. You
either believe it or you do not, and there is no third option, because there is
nothing on it to check.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/audit/&quot;&gt;Our audit page&lt;/a&gt; is not prose. It is generated at
build time — before Jekyll renders a single template — from machine-readable
reports that the hosts publish and sign themselves. That difference is the whole
post, so here is exactly how it works.&lt;/p&gt;

&lt;h2 id=&quot;what-the-page-is-made-of&quot;&gt;What the page is made of&lt;/h2&gt;

&lt;p&gt;Each host runs a scheduled job that checks the things we claim about it: that the
no-log invariants hold, that the resolver settings are what they are supposed to
be, that the unit files have not drifted. It writes a JSON report, signs it with a
detached &lt;strong&gt;ed25519&lt;/strong&gt; signature, and publishes the report, the signature, and the
accepted signer alongside each other.&lt;/p&gt;

&lt;p&gt;At build time, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/audit-report.rb&lt;/code&gt; fetches those artifacts over HTTPS and
verifies each signature with &lt;strong&gt;stock &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-keygen -Y verify&lt;/code&gt;&lt;/strong&gt; against the signer
key pinned in this site’s repository (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_data/audit-pins.json&lt;/code&gt;). Only then does it
substitute real numbers into the page you are reading.&lt;/p&gt;

&lt;p&gt;No YMRTECH software is involved in the verification step. That is deliberate: a
verification you have to trust our code to perform is not much better than a
sentence. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-keygen&lt;/code&gt; is on your machine already.&lt;/p&gt;

&lt;h2 id=&quot;what-the-build-refuses-to-do&quot;&gt;What the build refuses to do&lt;/h2&gt;

&lt;p&gt;The gate is not decorative. The build stops, and the deploy does not happen, if:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;a signature does not verify&lt;/strong&gt; — the artifact is not the bytes that were signed;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;a key rotates without being announced&lt;/strong&gt; — a new signer is not accepted just
because it signed something; the pin has to change in a reviewed commit first;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;a report claims to be a host it is not&lt;/strong&gt; — a valid signature from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mail&lt;/code&gt; does
not authorize a report about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vpn&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;a signed index disagrees with the file it describes&lt;/strong&gt; — a report whose hash is
not the hash the index recorded is a rewritten report.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is one case that is deliberately &lt;em&gt;not&lt;/em&gt; a failure: a host that is simply
unreachable. The page records it as unavailable and says so, because the
alternative — rendering a green box for a host nobody could reach — is exactly the
lie the rest of this design exists to prevent.&lt;/p&gt;

&lt;h2 id=&quot;why-the-failure-is-still-published&quot;&gt;Why the failure is still published&lt;/h2&gt;

&lt;p&gt;The most interesting property of the whole thing is what happens when a check
fails.&lt;/p&gt;

&lt;p&gt;The job writes and signs the report &lt;strong&gt;first&lt;/strong&gt;, and only then fails. A failing run
therefore leaves a valid, signed, published report at its URL saying that
something is wrong. Deleting the evidence of a failed check is not an option the
machinery offers, and that was the design goal: the moment when you would most
want to quietly tidy up is the moment the artifact is already out there.&lt;/p&gt;

&lt;h2 id=&quot;why-month-long-claims-are-still-claims-you-can-check&quot;&gt;Why month-long claims are still claims you can check&lt;/h2&gt;

&lt;p&gt;“Nothing in the log” on the day you visit the page is a small statement. The
month-long version is a bigger one, and it gets its own machinery: a monthly
roll-up recounts every dated daily report in the period and re-verifies each
signature, and a closed month is &lt;strong&gt;frozen&lt;/strong&gt; once published with its SHA-256
recorded in the index. A month that has already shipped cannot be revised later
without the index disagreeing with it — and the index disagreeing is one of the
conditions that fails this build.&lt;/p&gt;

&lt;h2 id=&quot;what-a-signature-does-not-prove&quot;&gt;What a signature does not prove&lt;/h2&gt;

&lt;p&gt;A signature proves &lt;strong&gt;origin&lt;/strong&gt;, not correctness. It proves a named host produced
these bytes and that nobody changed them afterwards. It does not prove the host
was telling the truth, and it does not make us independently audited.&lt;/p&gt;

&lt;p&gt;These reports are produced &lt;em&gt;by&lt;/em&gt; the infrastructure they describe. We hold no
third-party audit, no accredited certification, no SOC 2 report, and we do not
claim one — the &lt;a href=&quot;/standards/&quot;&gt;standards page&lt;/a&gt; is explicit
about which words we are allowed to use. And the audit’s own generator is not
public source: the boundary around what is public, and what that costs you, is
written out at &lt;a href=&quot;/audit/#the-public-source-boundary&quot;&gt;the public-source boundary&lt;/a&gt;,
on the same page. That section exists because a page full of cryptography invites
exactly the wrong conclusion, and the wrong conclusion is the one thing this is
not allowed to produce.&lt;/p&gt;

&lt;p&gt;The rest of the public posture — what we log, what we do not, and how the site’s
own stack is built — is on &lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;,
&lt;a href=&quot;/standards/&quot;&gt;standards&lt;/a&gt; and the
&lt;a href=&quot;/vpn/technical/&quot;&gt;technical dossier&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Staging is not production: how this site ships</title>
    <id>https://ymrtech.com/notes/staging-is-not-production/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/staging-is-not-production/"/>
    <published>2026-09-20T00:00:00-04:00</published>
    <updated>2026-09-20T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">Same branch, same gates, two different triggers. The pipeline that builds these pages, and the day a pull request was allowed to overwrite what main had already verified.</summary>
    
    <category term="delivery"/>
    
    
    <category term="ci"/>
    
    <category term="forgejo"/>
    
    <category term="staging"/>
    
    <category term="jekyll"/>
    
    <content type="html">&lt;p&gt;This website is built by a pipeline you can read: the workflow is
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.forgejo/workflows/ymrtech-deploy.yml&lt;/code&gt; in &lt;a href=&quot;https://git.ymrtech.com/ymrtech/ymrtech&quot;&gt;the same public repository&lt;/a&gt;
as the pages themselves. It is short enough to read in one sitting, and it
contains one design decision worth writing about, plus one incident that
produced it.&lt;/p&gt;

&lt;h2 id=&quot;three-triggers-two-environments&quot;&gt;Three triggers, two environments&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Event&lt;/th&gt;
      &lt;th&gt;What runs&lt;/th&gt;
      &lt;th&gt;What it writes&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Pull request&lt;/td&gt;
      &lt;td&gt;the full gate, plus a throwaway preview of the branch&lt;/td&gt;
      &lt;td&gt;nothing outside the preview root&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Push to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;the full gate again&lt;/td&gt;
      &lt;td&gt;staging only&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Manual dispatch with &lt;em&gt;promote to production&lt;/em&gt;&lt;/td&gt;
      &lt;td&gt;the full gate a third time&lt;/td&gt;
      &lt;td&gt;production&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; is the release branch for both environments. They differ by &lt;strong&gt;trigger&lt;/strong&gt;,
not by branch: merging to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; automatically verifies the exact commit that
will later go to production, and production only moves when a human dispatches
the promotion, which re-runs the entire suite before touching the prod docroot.&lt;/p&gt;

&lt;p&gt;The gates are the same in every job — icon font coverage, signed audit artifacts,
the Jekyll build, a check that the contact form is actually visible in the
rendered page rather than hiding in an HTML comment, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;htmlproofer&lt;/code&gt; over the built
site, a fragment-resolution check, the palette check, and the writing-content
check that guards these posts. Each job is self-contained, so a gate added in one
place has to be added in all four. That is a cost, and it is paid deliberately: a
job that depends on another job’s artifacts can be skipped into a false green.&lt;/p&gt;

&lt;h2 id=&quot;the-day-a-pull-request-overwrote-staging&quot;&gt;The day a pull request overwrote staging&lt;/h2&gt;

&lt;p&gt;The workflow’s own comments keep the scar tissue. Originally there was a single
job, and it ran its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rsync&lt;/code&gt; on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pull_request&lt;/code&gt; events too. The consequence: opening
or updating a PR — any PR, from any branch — replaced &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/srv/ymrtech-stage&lt;/code&gt; with
that branch’s build. Staging stopped meaning “what main verified” and started
meaning “whatever was pushed most recently”, which is the same as meaning nothing.&lt;/p&gt;

&lt;p&gt;The fix is one condition on the deploy job — staging is gated to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push&lt;/code&gt; and
explicit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workflow_dispatch&lt;/code&gt;, and the cleanup that removes a preview is gated to
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pull_request&lt;/code&gt; events instead. That one line is the difference between a staging
environment and a scratch directory.&lt;/p&gt;

&lt;h2 id=&quot;previews-get-a-path-not-a-hostname&quot;&gt;Previews get a path, not a hostname&lt;/h2&gt;

&lt;p&gt;Rather than give each branch a subdomain, a PR gets a &lt;strong&gt;path&lt;/strong&gt;:
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stage.ymrtech.com/preview/pr-&amp;lt;N&amp;gt;/&lt;/code&gt;, built with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--baseurl /preview/pr-&amp;lt;N&amp;gt;&lt;/code&gt;. That
works because every internal link and asset reference in this site is generated
through a Jekyll filter that applies the site’s baseurl — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;relative_url&lt;/code&gt; for
links, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asset_url&lt;/code&gt; for the handful of design assets, which adds the file’s own
content hash on top. There is nowhere to hardcode a root-relative path and have
it render, which is exactly the kind of property you fail to maintain until the
day you need it.&lt;/p&gt;

&lt;p&gt;The preview root is a &lt;strong&gt;separate tmpfs&lt;/strong&gt; from the staging docroot, so a staging
deploy (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rsync --delete&lt;/code&gt;) cannot erase a preview, and a preview cannot overwrite
staging. It is deleted when the PR closes. And because it is unreviewed branch
content published on a real domain, the pipeline verifies the &lt;em&gt;served&lt;/em&gt; URL after
publishing it: it polls for HTTP 200 and then requires the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X-Robots-Tag: noindex&lt;/code&gt;
and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cache-Control: no-store&lt;/code&gt; headers to be present. If those headers are missing,
the job fails — the assumption being that a response without them is not the
preview route that was supposed to answer.&lt;/p&gt;

&lt;h2 id=&quot;the-bits-that-only-show-up-in-production&quot;&gt;The bits that only show up in production&lt;/h2&gt;

&lt;p&gt;Two failures are worth keeping in mind as arguments for testing the real
artifact rather than the source:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;327 missing files.&lt;/strong&gt; The first real preview run failed html-proofer with 327
“does not exist” errors, because the site was built with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/preview/pr-&amp;lt;N&amp;gt;&lt;/code&gt;
baseurl while the proofer resolves internal references against the docroot on
disk, where no such directory exists. The fix is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--swap-urls&lt;/code&gt;, and the workflow
now documents why it takes &lt;em&gt;two&lt;/em&gt; replacements rather than one: with a single
pair the prefix is rewritten to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/&lt;/code&gt;, producing protocol-relative URLs like
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;//assets/x&lt;/code&gt;, which the proofer also rejects. All three variants — no swap, one
pair, two pairs — are reproduced in the comment, along with which one is clean.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A token that cannot be baked in.&lt;/strong&gt; The contact endpoint rejects
unauthenticated posts, so the form needs a token — but this repository is public
and a token in the source is a token you cannot rotate without a rebuild. The
deploy writes it from a host-readable secret file into a same-origin JSON
endpoint at deploy time, and if that file is missing the endpoint stays locked
and the form offers a mailto fallback instead of failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;why-this-is-here-rather-than-in-a-wiki&quot;&gt;Why this is here rather than in a wiki&lt;/h2&gt;

&lt;p&gt;Because it is the same argument as &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;:
a delivery claim you cannot inspect is a claim about intentions. The workflow is
public, the failure modes are documented next to the code that caused them, and
the pipeline that builds this page is the one that would have caught me getting it
wrong — the gate for these posts runs in all four jobs, and it fails the build if a
post’s stable slug and its rendered campaign tag stop agreeing.&lt;/p&gt;

&lt;p&gt;For the infrastructure underneath it — the hosts, the runner, the deploy health
gate and the automatic rollback — see &lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">What &quot;no logs&quot; can and cannot promise</title>
    <id>https://ymrtech.com/notes/what-no-logs-cannot-promise/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/what-no-logs-cannot-promise/"/>
    <published>2026-09-19T00:00:00-04:00</published>
    <updated>2026-09-19T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">A no-log policy is only meaningful when the exceptions are named. What this service records, what it does not, how each part is tested — and the four things a tunnel cannot protect you from.</summary>
    
    <category term="privacy"/>
    
    
    <category term="logging"/>
    
    <category term="privacy"/>
    
    <category term="audit"/>
    
    <category term="dns"/>
    
    <content type="html">&lt;p&gt;“No logs” is the most repeated sentence in this industry and the least defined.
Every provider says it; almost none of them tell you what the exceptions are, and
a policy that hides a meter is worse than one that explains it.&lt;/p&gt;

&lt;p&gt;So this is the whole list — the one already published, with the reasoning
attached, on &lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;what-is-not-recorded&quot;&gt;What is not recorded&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Browsing history.&lt;/strong&gt; Not recorded.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The DNS names you resolve.&lt;/strong&gt; Not recorded — on any tier. The resolver runs with
its query log disabled and its statistics disabled.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Traffic content.&lt;/strong&gt; Encrypted end to end. We carry it and cannot read it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Your ISP-assigned address,&lt;/strong&gt; in connection with your account.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Connection times.&lt;/strong&gt; No record of when you connected or disconnected.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Your device’s real network.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-is-recorded-and-why&quot;&gt;What is recorded, and why&lt;/h2&gt;

&lt;p&gt;Four rows. Not because four is a nice number, but because each one is something the
service cannot run without:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Data&lt;/th&gt;
      &lt;th&gt;Why it exists&lt;/th&gt;
      &lt;th&gt;Retention&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Email address, password hash&lt;/td&gt;
      &lt;td&gt;your account&lt;/td&gt;
      &lt;td&gt;life of the account&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Subscription and payment record&lt;/td&gt;
      &lt;td&gt;billing; card details are Stripe’s and never reach us&lt;/td&gt;
      &lt;td&gt;life of the account&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Device records — name, tier, tunnel IP, your public key&lt;/td&gt;
      &lt;td&gt;to provision and revoke access. Configuration, not activity&lt;/td&gt;
      &lt;td&gt;life of the account&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Aggregate data-volume counters&lt;/td&gt;
      &lt;td&gt;to keep the service fast and fair, and to stop abuse. Byte counts only: no destinations, no DNS, no browsing&lt;/td&gt;
      &lt;td&gt;currently up to 30 days, per device&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Plus one that arrived recently and is worth naming because it is unusual: the
&lt;strong&gt;speed test results you run yourself&lt;/strong&gt; from the portal — two rates, byte counts,
latency, and which of your own devices you labelled the test with. The five newest
per account, pruned as new ones arrive. It is the result that was on your screen
and nothing about the transfer behind it: no address at either end, no
destination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the counters.&lt;/strong&gt; The counters show &lt;em&gt;how much&lt;/em&gt; you transferred, not &lt;em&gt;what&lt;/em&gt;. A
byte total cannot be turned back into a browsing history, and it is never combined
with destination data because there is no destination data to combine it with. It
is disclosed for the same reason every row is: a meter you are not told about is a
meter you cannot argue with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server logs.&lt;/strong&gt; Ordinary operating-system logs — service starts and stops,
crashes, errors, deployments. Kept for &lt;strong&gt;14 days&lt;/strong&gt;, asserted by the automated audit
to contain no customer traffic or DNS data and no client-tier addresses.&lt;/p&gt;

&lt;h2 id=&quot;how-the-claim-is-tested-rather-than-asserted&quot;&gt;How the claim is tested rather than asserted&lt;/h2&gt;

&lt;p&gt;This is the part that separates a policy from a promise:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;A real query is sent, then the system checks that nothing was written.&lt;/strong&gt; A
DNS query goes through each resolver; the check then requires that no log
appeared. A &lt;strong&gt;canary name&lt;/strong&gt; — one that cannot occur by accident — is injected
for the same purpose, so “we found nothing” cannot be an artifact of looking in
the wrong place.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The result is signed and published, daily.&lt;/strong&gt; Each host signs a
machine-readable report with a detached ed25519 signature; the site’s build
verifies those signatures with stock &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-keygen&lt;/code&gt; and refuses to render if one
fails. The mechanism, and how to fetch and check it yourself, is on
&lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;A failure is published, not hidden.&lt;/strong&gt; A failing check still writes &lt;em&gt;and
signs&lt;/em&gt; the report first, then the unit fails — so a violated report exists at
its URL and stays there.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The infrastructure is declarative and version-controlled.&lt;/strong&gt; Nothing is
hand-edited on a running machine; what is published is what runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;the-warrant-canary-is-not-published-yet&quot;&gt;The warrant canary is not published yet&lt;/h2&gt;

&lt;p&gt;The policy commits to a &lt;strong&gt;signed, periodically renewed warrant canary at a stable
address&lt;/strong&gt;, recording how many disclosure demands have been received. It is not
published yet. Saying so is the point: a commitment listed next to things that are
already true, without a marker for which is which, is how “we’ve added X” quietly
becomes “we said we have X”. When it exists, it will be linked from
&lt;a href=&quot;/logging/&quot;&gt;what we log&lt;/a&gt;; until then, treat it as pending.&lt;/p&gt;

&lt;h2 id=&quot;what-a-tunnel-cannot-protect-you-from&quot;&gt;What a tunnel cannot protect you from&lt;/h2&gt;

&lt;p&gt;What a tunnel does not cover, in full:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;malware on your own device&lt;/strong&gt; — a tunnel is not an antivirus;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;logging in to an account that knows who you are&lt;/strong&gt; — Google still knows which
Google account you used;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;a browser using its own DNS&lt;/strong&gt; in a way your device does not route through the
tunnel. This is a real bypass, which is why the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;privacy&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;security&lt;/code&gt; tiers
put the resolver inside the tunnel and why the &lt;a href=&quot;/vpn/&quot;&gt;portal includes a leak
test&lt;/a&gt; that reports where a freshly minted name
actually went;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;anything you do after you disconnect.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A VPN hides your traffic from the network you are on. It does not hide you from
the services you deliberately log in to, and any provider that implies otherwise
is selling you a feeling.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title type="text">Tofu boxes: why the icons ship as ~6 KiB of font</title>
    <id>https://ymrtech.com/notes/icon-font-tofu-boxes/</id>
    <link rel="alternate" type="text/html" href="https://ymrtech.com/notes/icon-font-tofu-boxes/"/>
    <published>2026-09-18T00:00:00-04:00</published>
    <updated>2026-09-18T00:00:00-04:00</updated>
    <author>
      <name>Yannick M. Richard</name>
    </author>
    <summary type="text">The site&apos;s icons are private-use code points no system font maps. They are served from our own origin, subsetted to the glyphs actually in use, and a build gate fails if that ever stops being true.</summary>
    
    <category term="design"/>
    
    
    <category term="fonts"/>
    
    <category term="subsetting"/>
    
    <category term="ci"/>
    
    <category term="supply-chain"/>
    
    <content type="html">&lt;p&gt;&lt;strong&gt;Update (2026-10-16).&lt;/strong&gt; The icons no longer ship as a font. Every one is now an
inline SVG drawn from a registry in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_data/icons.yml&lt;/code&gt;, which reaches the outcome
this note argued for and could not yet get to: an inline SVG has no code point
that can fail to map, so the tofu box it is about cannot occur. The font, the
subset generator, the manifest and the coverage gate are gone. This note is left
as written — it is the record of why a subset was the right call then, and of
how the argument turned.&lt;/p&gt;

&lt;p&gt;The icons on this site are not images and they are not an icon library. They are
Nerd Font glyphs — private-use code points such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;U+F413&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;U+F08C8&lt;/code&gt;, drawn in
the same monospace font as the text.&lt;/p&gt;

&lt;p&gt;That is a nice trick right up until you remember the obvious: &lt;strong&gt;no stock system
font maps those code points.&lt;/strong&gt; A visitor who does not happen to have a Nerd Font
installed gets a tofu box — the hollow rectangle of a missing glyph — in front of
every link in the navigation. That is the failure the in-house font exists to
prevent.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-and-the-version-of-it-i-did-not-want&quot;&gt;The fix, and the version of it I did not want&lt;/h2&gt;

&lt;p&gt;The lazy fix is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; to a third-party font CDN. That is a runtime dependency
on someone else’s uptime, one more supply-chain hop on a page whose whole argument
is that you should be able to check things, and a reference that can stop resolving
without a single commit in this repository to warn you.&lt;/p&gt;

&lt;p&gt;So the font is served from this origin, from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assets/fonts/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Upstream:&lt;/strong&gt; &lt;a href=&quot;https://github.com/ryanoasis/nerd-fonts&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ryanoasis/nerd-fonts&lt;/code&gt;&lt;/a&gt;,
with the release tag pinned in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/nerd-font-subset.py&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Family:&lt;/strong&gt; JetBrainsMono Nerd Font Mono, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Regular&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bold&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Subset:&lt;/strong&gt; only the icon and graphic code points this repository actually uses;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Size:&lt;/strong&gt; roughly &lt;strong&gt;6 KiB per weight&lt;/strong&gt;;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Licence:&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OFL.txt&lt;/code&gt;, verbatim from upstream — SIL Open Font License 1.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six kilobytes is small enough to inline, which is a nice place to land: the page
carries its own icons, so drawing one is a local file read — nothing about the
glyphs depends on a host we do not control, and nothing changes under us when
somebody else’s URL does.&lt;/p&gt;

&lt;h2 id=&quot;the-generated-files-are-generated&quot;&gt;The generated files are generated&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/nerd-font-subset.py&lt;/code&gt; scans the repository for glyphs in the icon ranges —
arrows, box drawing, block elements, geometric shapes, and the two Nerd Font
private-use blocks — subsets the pinned upstream release down to &lt;strong&gt;exactly those
glyphs&lt;/strong&gt;, writes the two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;woff2&lt;/code&gt; files, deletes the ones it superseded, and
rewrites the generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@font-face&lt;/code&gt; block in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assets/css/style.css&lt;/code&gt; in place.&lt;/p&gt;

&lt;p&gt;Two details in that script are load-bearing:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Latin text and general punctuation are deliberately &lt;em&gt;not&lt;/em&gt; in the subset.&lt;/strong&gt;
Those code points keep falling through the font stack to Fira Code and then to
the platform monospace font, exactly as they did before. Adding a 300 KiB text
font to fix 6 KiB of icons would have been a different decision with a different
cost.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The file name embeds a content hash.&lt;/strong&gt; A stylesheet is referenced with a
version query, and the font file it points at is named after its own bytes, so a
cached copy of an old stylesheet can never pin a stale subset. The generated
files are committed together with the stylesheet change that references them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-failure-is-silent-so-there-is-a-gate&quot;&gt;The failure is silent, so there is a gate&lt;/h2&gt;

&lt;p&gt;This is the kind of bug that returns without warning. A missing glyph does not
produce a 404 — the font loads fine, and then draws a box. A glyph dropped from the
stylesheet’s coverage looks exactly like a glyph nobody ever wrote.&lt;/p&gt;

&lt;p&gt;So coverage is asserted at build time, in &lt;strong&gt;all four&lt;/strong&gt; CI jobs:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/icon-codepoints.txt&lt;/code&gt; records exactly which code-point spans the shipped
subset covers.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tools/check-icon-coverage.rb&lt;/code&gt; re-scans the sources on every build and fails if
the site uses a glyph the subset does not cover — or if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style.css&lt;/code&gt; references a
font file that is not in the repository.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The consequence is the property worth having: &lt;strong&gt;adding a new icon without
regenerating the subset is a red build, not a tofu box in production.&lt;/strong&gt; The
mistake is now louder than the fix.&lt;/p&gt;

&lt;p&gt;The same reasoning runs through the rest of the theme — colour is checked against
declared tokens, so the palette cannot drift out of the dark/light switch either.
See &lt;a href=&quot;/infra/&quot;&gt;how the stack is built&lt;/a&gt; for the rest of
the pipeline, and &lt;a href=&quot;/audit/&quot;&gt;the audit page&lt;/a&gt; for the version
of this argument that concerns signatures rather than glyphs.&lt;/p&gt;
</content>
  </entry>
  
</feed>
