<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Notes · XB Field Notes</title><link>https://hk.xbcatm.com/notes/</link><description>Illustrated field notes on HTTP, DNS, TLS, Caddy, SSH, releases, and recovery.</description><generator>Hugo</generator><language>en-us</language><atom:link href="https://hk.xbcatm.com/notes/index.xml" rel="self" type="application/rss+xml"/><item><title>A maintenance timer should leave its last result visible</title><link>https://hk.xbcatm.com/notes/a-maintenance-timer-should-leave-its-last-result-visible/</link><pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-maintenance-timer-should-leave-its-last-result-visible/</guid><description>Pair a oneshot service with a timer, bounded runtime, and journal evidence.</description><content:encoded>&lt;p&gt;Cron-like syntax is not the whole contract for a maintenance task. The command needs a clear service identity, a bounded runtime, and evidence that distinguishes “not scheduled” from “ran and failed”. A systemd timer and oneshot service keep those concerns separate.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/maintenance-timer.detail.en.svg" alt="Hourly scheduling, randomized delay, oneshot activation and result evidence appear as separate timeline stages. Missed calendar events coalesce into one catch-up and still incur random delay. A two-minute activation timeout is qualified by stop handling and bounded helper requests. Success can end inactive; Result, exit status and a fresh journal timestamp must agree. An already active service is not started again by the recurring timer." width="720" height="3270"&gt;&lt;figcaption&gt;A fired timer is only a scheduling event. Judge this run by its service result, exit status and fresh timestamp.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="define-the-work-as-a-service"&gt;Define the work as a service&lt;/h2&gt;
&lt;p&gt;Use an explicit user and absolute command path:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# /etc/systemd/system/site-check.service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Read-only public site check&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sitecheck&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/check-public-site&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;TimeoutStartSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2min&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;TimeoutStartSec&lt;/code&gt; limits the oneshot activation, which includes its command runtime; oneshot services otherwise have no start timeout by default. Timeout handling can then require a stop timeout and cannot instantly end uninterruptible kernel I/O, so the helper also needs bounded network requests. The helper should return non-zero when its contract fails and must not modify the service it checks.&lt;/p&gt;
&lt;h2 id="schedule-the-service"&gt;Schedule the service&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# /etc/systemd/system/site-check.timer&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run the public site check every hour&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Timer]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;OnCalendar&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;hourly&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Persistent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5min&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;site-check.service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Persistent=true&lt;/code&gt; triggers one catch-up activation when the timer is activated after missing at least one calendar event; it does not replay every missed hour. The catch-up is still subject to the randomized delay. &lt;code&gt;RandomizedDelaySec&lt;/code&gt; spreads routine work across a window; it is not appropriate when the task must run at an exact instant.&lt;/p&gt;
&lt;h2 id="validate-before-enabling"&gt;Validate before enabling&lt;/h2&gt;
&lt;p&gt;After creating both unit files, validate them and reload the manager before starting the service manually. These are administrative operations on the intended test host, not read-only checks:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd-analyze verify /etc/systemd/system/site-check.service &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; /etc/systemd/system/site-check.timer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd-analyze calendar hourly
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl daemon-reload
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl start site-check.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl status site-check.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;journalctl -u site-check.service -n &lt;span class="m"&gt;50&lt;/span&gt; --no-pager
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl show site-check.service -p Result -p ExecMainCode -p ExecMainStatus
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Enable the timer only after the service succeeds with the intended user, environment, paths, and network access:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; --now site-check.timer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl list-timers site-check.timer
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="alert-on-the-service-result"&gt;Alert on the service result&lt;/h2&gt;
&lt;p&gt;The timer firing is not success. Monitor the oneshot service&amp;rsquo;s exit status or journal result. Keep stdout concise and send detailed artifacts to a bounded path if they are needed for debugging.&lt;/p&gt;
&lt;p&gt;A successful oneshot normally returns to &lt;code&gt;inactive (dead)&lt;/code&gt;; that alone is not a failure, and &lt;code&gt;systemctl status&lt;/code&gt; can return nonzero for an inactive service. Correlate &lt;code&gt;Result&lt;/code&gt; and exit status with a fresh journal timestamp so an old successful run is not mistaken for this one. Do not add &lt;code&gt;RemainAfterExit=yes&lt;/code&gt; merely to keep it green: a recurring timer does not start a fresh instance while its service is already active. Calendar timing uses the host timezone unless specified, and normal timer accuracy adds scheduling slack beyond the random delay.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man5/systemd.timer.5.html"&gt;systemd.timer(5)&lt;/a&gt;, calendar timers, persistence, and delay behavior, checked 2026-08-04.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man7/systemd.time.7.html"&gt;systemd.time(7)&lt;/a&gt;, calendar expression syntax, checked 2026-08-04.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man5/systemd.service.5.html"&gt;systemd.service&lt;/a&gt;, oneshot service semantics, checked 2026-08-04.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/systemd/systemd/v257/man/systemd.service.xml"&gt;systemd.service upstream manual (v257)&lt;/a&gt;, Type=exec/oneshot, readiness, activation timeout, and restart semantics, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/systemd/systemd/v257/man/systemd.timer.xml"&gt;systemd.timer upstream manual (v257)&lt;/a&gt;, persistence coalesces missed runs; active units, randomized delay, and accuracy, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Restart policies need a rate limit</title><link>https://hk.xbcatm.com/notes/restart-policies-need-a-rate-limit/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/restart-policies-need-a-rate-limit/</guid><description>Let systemd recover transient failures without hiding a permanent configuration error.</description><content:encoded>&lt;p&gt;Automatic restart is useful for a long-running service, but an unbounded loop can turn a clear failure into a noisy one. The unit should describe both the normal process lifecycle and the amount of recovery that is acceptable.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/restart-loop.detail.en.svg" alt="Execution, application readiness and healthy responses are separate checks. An illustrative configured window allows five starts in 60 seconds, with five-second delays after rapid failures. A further attempt hits the limiter and automatic restarts stop. Slower failures can remain below the rate indefinitely. Time passing alone does not schedule another attempt; fix the cause and deliberately start or clear the counter before starting. Retain the first failure log and verify readiness and healthy responses." width="720" height="3410"&gt;&lt;figcaption&gt;Count starts within the configured window. A restart rate limit stops rapid churn; it neither proves health nor caps lifetime retries.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="track-setup-failures"&gt;Track setup failures&lt;/h2&gt;
&lt;p&gt;For a long-running process, &lt;code&gt;Type=exec&lt;/code&gt; makes failures to execute the configured program visible during start-up rather than treating a fork as success. Pair it with an explicit &lt;code&gt;ExecStart&lt;/code&gt; and the least-privileged &lt;code&gt;User&lt;/code&gt; that can perform the work.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Example foreground site service&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60s&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;site&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/srv/site/bin/server&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;RestartSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5s&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Type=exec&lt;/code&gt; confirms execution, not application readiness. Keep the program in the foreground; use &lt;code&gt;Type=notify&lt;/code&gt; when the application supports explicit readiness notification. &lt;code&gt;Restart=on-failure&lt;/code&gt; is a recovery policy, not a health check. If the process stays alive while serving bad responses, systemd will not know that the site is broken; an external smoke test still has a job.&lt;/p&gt;
&lt;h2 id="bound-the-loop"&gt;Bound the loop&lt;/h2&gt;
&lt;p&gt;Systemd applies start-rate limiting through &lt;code&gt;StartLimitIntervalSec=&lt;/code&gt; and &lt;code&gt;StartLimitBurst=&lt;/code&gt;. These directives belong in &lt;code&gt;[Unit]&lt;/code&gt;, while &lt;code&gt;RestartSec&lt;/code&gt; belongs in &lt;code&gt;[Service]&lt;/code&gt;. The example allows five starts within 60 seconds; rapid failures separated by five-second restart delays can therefore hit the limit. A longer delay can keep a loop below that rate forever: this is not a lifetime retry budget. Choose limits from the expected failure pattern. When the limit is hit, the journal should contain the evidence needed to distinguish “restarted successfully” from “gave up after repeated failure”.&lt;/p&gt;
&lt;p&gt;After installing or editing the unit on an authorized test host, validate the file, reload the manager, and inspect its effective configuration before restarting. The commands below change service state and can interrupt traffic:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd-analyze verify /etc/systemd/system/example.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl daemon-reload
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl cat example.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl show example.service -p Restart -p RestartUSec &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -p StartLimitIntervalUSec -p StartLimitBurst
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl restart example.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemctl status --no-pager example.service
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;journalctl -u example.service -n &lt;span class="m"&gt;80&lt;/span&gt; --no-pager
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Do not use a restart policy to conceal missing files, invalid permissions, or a bad environment. Fix the first failure, then test the recovery path.&lt;/p&gt;
&lt;p&gt;Once start limiting stops automatic restarts, waiting for the interval alone does not schedule another attempt. After fixing the cause, either start it after the interval or deliberately use &lt;code&gt;systemctl reset-failed example.service&lt;/code&gt; to clear the counter before starting. Test missing-executable and rapid-crash cases in an isolated service, and retain the first failure log rather than only the final “start limit hit” message.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man5/systemd.service.5.html"&gt;systemd.service&lt;/a&gt;, service type, restart, and start-rate limiter, checked 2026-08-03.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man5/systemd.unit.5.html"&gt;systemd.unit&lt;/a&gt;, common unit sections and drop-in semantics, checked 2026-08-03.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/systemd/systemd/v257/man/systemd.service.xml"&gt;systemd.service upstream manual (v257)&lt;/a&gt;, Type=exec/oneshot, readiness, activation timeout, and restart semantics, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/systemd/systemd/v257/man/systemd.unit.xml"&gt;systemd.unit upstream manual (v257)&lt;/a&gt;, start-limit section placement, rate window, and reset-failed behavior, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A release checklist for a static site</title><link>https://hk.xbcatm.com/notes/a-release-checklist-for-a-static-site/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-release-checklist-for-a-static-site/</guid><description>A compact pre-publish check for generated HTML, links, assets, and response headers.</description><content:encoded>&lt;p&gt;A static site is simple to publish, but simplicity does not remove the need for a release check. The useful checks are small enough to run on every release and specific enough to fail for a real reason.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/release-checklist.detail.en.svg" alt="Four numbered release phases build a clean artifact with a pinned generator, require nonempty index.html, 404.html, robots.txt, and sitemap.xml plus reviewed links, test the exact artifact at staged and then public routes, and record commit, generator version, and download checksums. A separate Hugo preview rebuilds source; HEAD checks metadata while GET enables body comparison." width="720" height="2570"&gt;&lt;figcaption&gt;The checks belong to the generated artifact. Clean output does not establish reproducibility; placeholder searches are review aids, with parser-based link checks as the gate. Serve the exact artifact before publishing, compare public GET bodies afterward in both languages, and keep the commit, generator version, and download checksums.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="build-a-clean-artifact"&gt;Build a clean artifact&lt;/h2&gt;
&lt;p&gt;Use a pinned generator version and build into a disposable output directory:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo version
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo --cleanDestinationDir --gc --minify --destination public
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--gc&lt;/code&gt; collects unused build-cache resources; it does not clean stale published pages. &lt;code&gt;--cleanDestinationDir&lt;/code&gt; removes stale destination files, so use it only on the disposable artifact directory, never a directory containing source or unrelated data. Compare the printed version with the project&amp;rsquo;s pin; printing a version does not enforce it. Record the build inputs and verify the resulting artifact. A clean destination does not establish byte-for-byte reproducibility: timestamps and other nondeterministic inputs need separate control and comparison. Do not edit generated HTML by hand after the build.&lt;/p&gt;
&lt;h2 id="check-the-artifact-not-only-the-source"&gt;Check the artifact, not only the source&lt;/h2&gt;
&lt;p&gt;Inspect the generated tree and verify that expected entry points exist. Run this assertion block as a standalone shell script; it must stop before printing the inventory if any required file is missing or empty:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -s public/index.html
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -s public/404.html
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -s public/robots.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -s public/sitemap.xml
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;find public -type f -print &lt;span class="p"&gt;|&lt;/span&gt; sort
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then search for accidental placeholders, local paths, and development-only hosts:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rg -l &lt;span class="s1"&gt;&amp;#39;localhost|127\.0\.0\.1|TODO|example\.com|/Users/&amp;#39;&lt;/span&gt; public --glob &lt;span class="s1"&gt;&amp;#39;*.html&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is a review aid, not an automatic release gate: examples like this checklist deliberately match, including in translated pages. This HTML-only scan does not cover XML feeds or JSON search output. &lt;code&gt;rg&lt;/code&gt; exits 1 when there are no matches and 2 on an error. Review each listed file, distinguish fenced examples from active links or attributes, and use a parser-based link check for the actual gate. A private path in a published link is a release mistake.&lt;/p&gt;
&lt;h2 id="exercise-the-public-routes"&gt;Exercise the public routes&lt;/h2&gt;
&lt;p&gt;An isolated production-like server pointed at this exact artifact gives the strongest pre-release routing check. Hugo’s development server rebuilds content; the following is a separate source preview, not evidence that the already checked &lt;code&gt;public/&lt;/code&gt; bytes were served. Run the first command in one terminal and the requests in another:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo server --bind 127.0.0.1 --port &lt;span class="m"&gt;1313&lt;/span&gt; --baseURL http://127.0.0.1:1313/ &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --disableFastRender --disableLiveReload --renderToMemory
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -I http://127.0.0.1:1313/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -I http://127.0.0.1:1313/notes/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -I http://127.0.0.1:1313/resources/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -I http://127.0.0.1:1313/missing-page/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;HEAD checks do not compare bodies. Repeat with GET against the staged server, then after publishing compare public bodies with the artifact and check both languages. Confirm that the missing route returns a 404 status in the production server. A pretty error page with a 200 status still breaks monitoring and caches.&lt;/p&gt;
&lt;h2 id="record-the-result"&gt;Record the result&lt;/h2&gt;
&lt;p&gt;Keep the source commit, generator version, and checksum of each downloadable resource in the release record. This makes it possible to compare a live file with the source that produced it without relying on memory.&lt;/p&gt;
&lt;p&gt;The downloadable version of this checklist is in the &lt;a href="https://hk.xbcatm.com/resources/"&gt;Resources&lt;/a&gt; section.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gohugo.io/commands/hugo/"&gt;Hugo command reference&lt;/a&gt;, generator flags for a clean production build, checked 2026-07-30.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, HTTP verification and header inspection options, checked 2026-07-30.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/resources/"&gt;Static-site release checklist&lt;/a&gt;, downloadable companion resource.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gohugo.io/commands/hugo_server/"&gt;Hugo server command reference&lt;/a&gt;, development rebuilds, memory rendering, and live-reload controls, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>SSH host keys are part of the deployment record</title><link>https://hk.xbcatm.com/notes/ssh-host-keys-are-part-of-the-deployment-record/</link><pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/ssh-host-keys-are-part-of-the-deployment-record/</guid><description>Keep first contact explicit and reject unexpected host-key changes.</description><content:encoded>&lt;p&gt;In public-key authentication, a client proves possession of its private key; it does not tell the client which server it reached. The server&amp;rsquo;s host key and the local &lt;code&gt;known_hosts&lt;/code&gt; database provide that second half of the identity check.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/ssh-host-key.detail.en.svg" alt="Client private-key authentication and server host-key identity are separate checks. Compare the observed candidate with an independently trusted owner or console fingerprint before installation. yes refuses unknown and changed keys; accept-new accepts unseen keys without independent proof but refuses changes. Review rotations, keep private operational records, and do not treat ssh -G as a handshake." width="720" height="2300"&gt;&lt;figcaption&gt;SSH server identity depends on comparing the observed host key with a separately trusted reference. Strict checking rejects unknown or changed keys. Trust on first use and a network key scan do not independently authenticate first contact.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="separate-first-contact-from-rotation"&gt;Separate first contact from rotation&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;StrictHostKeyChecking=yes&lt;/code&gt;, SSH refuses unknown hosts and changed keys. That is the safest default for a fixed production endpoint, provided the expected key is installed through a trusted channel. &lt;code&gt;accept-new&lt;/code&gt; is a narrower convenience for a fleet where new hosts are expected: it accepts a never-seen key but still refuses a changed key.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code class="language-sshconfig" data-lang="sshconfig"&gt;Host production-site
HostName www.example.test
User deploy
IdentityFile ~/.ssh/site_ed25519
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Do not “fix” a warning by adding &lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt; to a deployment script. It permits connections despite changed host keys, subject to client restrictions; warnings may still be printed, but the intended fail-closed identity check has been weakened. If a host is rebuilt, record the replacement fingerprint and review why the old key is no longer valid.&lt;/p&gt;
&lt;h2 id="make-the-fingerprint-reviewable"&gt;Make the fingerprint reviewable&lt;/h2&gt;
&lt;p&gt;Obtain the fingerprint from the host owner or console before the first connection, then compare it with what the client sees. A key change should have a ticket, maintenance note, or other explanation that another operator can inspect.&lt;/p&gt;
&lt;p&gt;The same rule applies to automation: the known-hosts file is configuration with security consequences, not disposable cache data. Back it up with the deployment record, but do not publish private keys or copied credentials with the site artifact.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;accept-new&lt;/code&gt; is trust on first use, not independent proof that the first server was genuine. &lt;code&gt;ssh-keyscan&lt;/code&gt; can collect a candidate public key, but fetching it over the same untrusted network does not authenticate it. Compare its fingerprint with the separately trusted value before installing it.&lt;/p&gt;
&lt;p&gt;Inspect the effective configuration without connecting, using a standalone copy of the example saved as &lt;code&gt;deploy.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -G -F ./deploy.conf production-site &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; grep -E &lt;span class="s1"&gt;&amp;#39;^(hostname|user|stricthostkeychecking|userknownhostsfile) &amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Expect the configured host, user, known-hosts path, and strict checking (&lt;code&gt;true&lt;/code&gt; or &lt;code&gt;yes&lt;/code&gt;, depending on client output). This parses configuration only; it does not test an actual handshake. For a nonstandard port, known-hosts entries normally use &lt;code&gt;[hostname]:port&lt;/code&gt;. Keep public-key fingerprints with the private operational release record, not the public website; hostnames can reveal internal topology even though host public keys are not secrets.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/ssh_config"&gt;OpenBSD &lt;code&gt;ssh_config&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;StrictHostKeyChecking&lt;/code&gt; and &lt;code&gt;UserKnownHostsFile&lt;/code&gt;, checked 2026-07-28.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/ssh-keygen"&gt;OpenSSH &lt;code&gt;ssh-keygen&lt;/code&gt; manual&lt;/a&gt;, fingerprint inspection, checked 2026-07-28.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/ssh-keyscan"&gt;OpenSSH ssh-keyscan&lt;/a&gt;, unverified collected keys do not prevent man-in-the-middle attacks, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.openbsd.org/ssh"&gt;OpenSSH ssh&lt;/a&gt;, -G prints evaluated client configuration without opening a session, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>The advantage of small systems</title><link>https://hk.xbcatm.com/notes/the-advantage-of-small-systems/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/the-advantage-of-small-systems/</guid><description>A system is easier to trust when its important behavior fits inside a single mental model.</description><content:encoded>&lt;p&gt;Small does not mean unfinished. It means each component has a clear job, dependencies are deliberate, and the path from request to result remains visible.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/small-system.detail.en.svg" alt="DNS lookup returns an address to the client without carrying HTTP. The request reaches the intended TLS edge and one known output directory. A file decision leads to intended content or a site-handled 404. Responsibility fields name DNS, edge and release owners; file provenance and recovery requirements remain explicit. Independent backup and recovery drills address single points of failure." width="720" height="3030"&gt;&lt;figcaption&gt;Name each request boundary, its evidence and its owner. Fewer components reduce the paths to understand; backups and recovery practice still carry the recovery promise.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="visibility-is-a-feature"&gt;Visibility is a feature&lt;/h2&gt;
&lt;p&gt;When operators can name every boundary, they can also identify which evidence is missing. Logs become more useful, alerts become more specific, and routine maintenance becomes less risky.&lt;/p&gt;
&lt;p&gt;For a static website the important boundaries are usually easy to list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DNS points the hostname to the intended edge.&lt;/li&gt;
&lt;li&gt;TLS terminates at the intended proxy.&lt;/li&gt;
&lt;li&gt;The proxy serves one known output directory.&lt;/li&gt;
&lt;li&gt;The output contains only the files the site needs.&lt;/li&gt;
&lt;li&gt;A 404 response is handled by the site, not by an accidental upstream.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Writing this list down is more valuable than adding a dashboard that does not answer any of those questions.&lt;/p&gt;
&lt;h2 id="complexity-should-earn-its-place"&gt;Complexity should earn its place&lt;/h2&gt;
&lt;p&gt;A new layer is worthwhile when it removes more uncertainty than it creates. A build step that fingerprints assets can be useful when caching is important. A database is useful when content needs transactions. Neither is automatically an improvement for a small, mostly static site.&lt;/p&gt;
&lt;p&gt;The same rule applies to client-side JavaScript. If a page genuinely requires client-side scripting, choose the smallest implementation that meets the requirement and review its content security policy deliberately. For this site, that would first require revisiting the accepted zero-script architecture and its verification contract; it is not an exception to the current policy. If the page is a document, plain HTML is often easier to inspect and more resilient.&lt;/p&gt;
&lt;h2 id="make-ownership-explicit"&gt;Make ownership explicit&lt;/h2&gt;
&lt;p&gt;Every file should have a reason to exist and a clear way to update it. A vendored theme should record its upstream version and license. A generated directory should be marked as generated. A resource download should say what it contains, when it was updated, and how its checksum was produced.&lt;/p&gt;
&lt;p&gt;These small statements prevent future operators from treating an accidental artifact as a supported interface.&lt;/p&gt;
&lt;h2 id="leave-room-for-the-next-change"&gt;Leave room for the next change&lt;/h2&gt;
&lt;p&gt;The best small system is not the one with the fewest lines. It is the one where the next change has a visible place to go. A notes site can start with Markdown, a static generator, and a web server; if it later needs search or comments, the new requirement can be evaluated on its own rather than assumed from the beginning.&lt;/p&gt;
&lt;p&gt;Small is not automatically fault-tolerant. A single edge, disk, or operator can still be a single point of failure. Choose acceptable recovery time and data loss, keep an independent backup, and rehearse recovery. The benefit of fewer components is a smaller set of failure paths to understand, not immunity to outages.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gohugo.io/getting-started/directory-structure/"&gt;Hugo directory structure&lt;/a&gt;, directory conventions for content and assets, checked 2026-07-27.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/file_server"&gt;Caddy &lt;code&gt;file_server&lt;/code&gt; directive&lt;/a&gt;, static file serving defaults referenced by the design, checked 2026-07-27.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Security headers should describe the actual page</title><link>https://hk.xbcatm.com/notes/security-headers-should-describe-the-actual-page/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/security-headers-should-describe-the-actual-page/</guid><description>A compact policy is safer when it matches the resources a static page really loads.</description><content:encoded>&lt;p&gt;Security headers are not a substitute for secure code, but they make the browser enforce useful boundaries. The policy should describe the page that is actually served, not a generic template copied from another project.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/security-layers.detail.en.svg" alt="The actual HTML, local stylesheet and favicon determine CSP resource allowances. Content-Security-Policy enforces; Report-Only reveals violations and needs separate reporting setup. Deliver CSP as a header: frame-ancestors is ignored in meta and has no default-src fallback, form-action needs its own directive, and style-src self alone does not allow inline styles. HSTS is learned from valid HTTPS responses; includeSubDomains at www.example.test covers that host and descendants, not example.test or api.example.test. Check normal, missing and asset responses and verify enforcement in a real browser. No browser test is claimed." width="720" height="3460"&gt;&lt;figcaption&gt;Treat headers as a deployment contract: match actual resources, distinguish enforced and report-only policy, and verify exact host scope and browser behavior.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="start-from-the-resource-graph"&gt;Start from the resource graph&lt;/h2&gt;
&lt;p&gt;If a site serves HTML, a local stylesheet, a favicon, and no executable JavaScript, a narrow policy can be understandable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Content-Security-Policy: default-src &amp;#39;self&amp;#39;; base-uri &amp;#39;self&amp;#39;; form-action &amp;#39;none&amp;#39;; frame-ancestors &amp;#39;none&amp;#39;; object-src &amp;#39;none&amp;#39;; script-src &amp;#39;none&amp;#39;; style-src &amp;#39;self&amp;#39;; img-src &amp;#39;self&amp;#39; data:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;script-src 'none'&lt;/code&gt; choice is only correct while the site does not require executable scripts. Adding search, comments, or an analytics tag changes the resource graph and should trigger a deliberate policy review.&lt;/p&gt;
&lt;h2 id="add-transport-and-framing-protections"&gt;Add transport and framing protections&lt;/h2&gt;
&lt;p&gt;For an HTTPS-only site, the following headers are common starting points:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Strict-Transport-Security: max-age=31536000; includeSubDomains
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Content-Type-Options: nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Frame-Options: DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Referrer-Policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Only enable HSTS with includeSubDomains when every subdomain is prepared to serve HTTPS. A header is a contract with future deployments, not merely a score in a scanner.&lt;/p&gt;
&lt;h2 id="check-behavior-not-just-presence"&gt;Check behavior, not just presence&lt;/h2&gt;
&lt;p&gt;Request a normal page, a missing page, and a static asset. Error responses should carry the same important security headers as successful responses. Also confirm that compression and caching do not change the content type or expose an internal path.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://hk.xbcatm.com/resources/"&gt;Caddy example&lt;/a&gt; in this site is a starting point, not a universal policy. Adapt the host names, certificate handling, and application routes to your own deployment.&lt;/p&gt;
&lt;p&gt;Deliver the CSP as a response header. &lt;code&gt;frame-ancestors&lt;/code&gt; is ignored in a meta policy and does not inherit &lt;code&gt;default-src&lt;/code&gt;; &lt;code&gt;form-action&lt;/code&gt; also needs its own directive. &lt;code&gt;style-src 'self'&lt;/code&gt; does not allow inline styles by itself, so test the generated theme, including dark mode. Keep &lt;code&gt;data:&lt;/code&gt; in &lt;code&gt;img-src&lt;/code&gt; only when the page actually needs data images.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; can reveal violations during a rollout, but does not enforce the policy; a reporting endpoint needs separate configuration. Verify enforcement in a real browser as well as checking headers. HSTS is learned from valid HTTPS responses; with &lt;code&gt;includeSubDomains&lt;/code&gt; it also applies to descendants of the host that sets it: a policy on &lt;code&gt;www.example.test&lt;/code&gt; does not cover the parent &lt;code&gt;example.test&lt;/code&gt; or sibling &lt;code&gt;api.example.test&lt;/code&gt;. Gradually increase the lifetime after checking that exact scope.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;MDN: Content Security Policy guide&lt;/a&gt;, CSP directive names and syntax, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security"&gt;MDN: &lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/a&gt;, HSTS directives and deployment cautions, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/header"&gt;Caddy &lt;code&gt;header&lt;/code&gt; directive&lt;/a&gt;, response header manipulation directives, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/TR/CSP3/"&gt;W3C Content Security Policy Level 3&lt;/a&gt;, response-header delivery, directive fallbacks, framing, and report-only policies; current working draft, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6797.html"&gt;RFC 6797 — HTTP Strict Transport Security&lt;/a&gt;, valid HTTPS delivery and host/descendant matching in sections 8.1–8.3, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>robots.txt and sitemaps answer different questions</title><link>https://hk.xbcatm.com/notes/robots.txt-and-sitemaps-answer-different-questions/</link><pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/robots.txt-and-sitemaps-answer-different-questions/</guid><description>Keep crawler preferences, URL discovery, and access control separate.</description><content:encoded>&lt;p&gt;Two small files are often treated as if they were a security boundary. They are not. A &lt;code&gt;robots.txt&lt;/code&gt; file describes crawler preferences, while a sitemap gives search systems a list of URLs that belong to the site.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/discovery-map.detail.en.svg" alt="robots.txt at the origin root is a UTF-8 preference file for cooperating crawlers, not authentication or reliable de-indexing. Inspect the sitemap root: follow sitemapindex children such as language sitemaps, or read a direct urlset. Use canonical absolute URLs from one host. XML syntax alone is insufficient: check namespace, count, host and page existence; each sitemap is limited to 50,000 URLs and 52,428,800 uncompressed bytes, unchanged by gzip. Private routes need server authorization or a network boundary. RFC 9309 permits crawling for unavailable robots with 4xx; network/server errors initially require complete disallow. A reachable 200 HTML fallback is not proof of a correct robots file." width="720" height="3560"&gt;&lt;figcaption&gt;Discovery aids do not grant or deny access. Follow every listed sitemap, validate more than XML syntax, and handle robots error responses according to their actual class.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="what-robotstxt-can-and-cannot-do"&gt;What robots.txt can and cannot do&lt;/h2&gt;
&lt;p&gt;The rules are published at the origin&amp;rsquo;s &lt;code&gt;/robots.txt&lt;/code&gt; path and are read by cooperating crawlers. They do not protect a private directory, remove a URL from the network, or replace authentication. A route that must not be public needs server authorization or a network boundary.&lt;/p&gt;
&lt;p&gt;Keep the file boring and test its actual location:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;User-agent: *
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Disallow:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Sitemap: https://www.example.test/sitemap.xml
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file should be UTF-8. If a deployment redirects it, verify the crawler behavior you intend rather than assuming every client follows the same chain.&lt;/p&gt;
&lt;h2 id="what-a-sitemap-adds"&gt;What a sitemap adds&lt;/h2&gt;
&lt;p&gt;A sitemap is an inventory, not a permission list. Use absolute URLs from one host, encode XML values correctly, and include only canonical pages that you are prepared to publish. Do not list a URL merely because it exists in a build directory.&lt;/p&gt;
&lt;p&gt;Each sitemap may contain at most 50,000 URLs and 50 MB (52,428,800 bytes) uncompressed; gzip does not raise that limit. An index is optional for a small site, but multilingual generators may legitimately produce one pointing to separate language sitemaps. Inspect the root element and follow each listed sitemap rather than assuming &lt;code&gt;/sitemap.xml&lt;/code&gt; is a URL set. Validate it as XML and request it through the same HTTPS edge as a normal page.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -fsS https://www.example.test/robots.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -fsS -o sitemap.xml https://www.example.test/sitemap.xml &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; xmllint --noout sitemap.xml
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Separate download and parsing so a failed fetch cannot be hidden by a successful parser. Well-formed XML is only a syntax check: also validate the namespace, URL count, canonical hosts, and whether listed pages exist. If &lt;code&gt;xmllint&lt;/code&gt; is not installed, parse the XML in the same CI environment that publishes it. The important part is that the check runs before the file is replaced at the edge.&lt;/p&gt;
&lt;p&gt;A robots &lt;code&gt;Disallow&lt;/code&gt; is not a reliable de-indexing instruction. Also test error responses: RFC 9309 permits crawling when robots is unavailable with a 4xx response, while a network/server error initially requires complete disallow. Accidentally serving the HTML fallback with status 200 can therefore change crawler behavior even though the URL appears reachable.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9309.html"&gt;RFC 9309 — Robots Exclusion Protocol&lt;/a&gt;, robots rules, file location, and user-agent matching, checked 2026-07-21.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.sitemaps.org/protocol.html"&gt;Sitemaps Protocol&lt;/a&gt;, sitemap XML structure and discovery, checked 2026-07-21.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Reliable defaults beat clever recovery</title><link>https://hk.xbcatm.com/notes/reliable-defaults-beat-clever-recovery/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/reliable-defaults-beat-clever-recovery/</guid><description>A predictable first path prevents more incidents than a complicated fallback can repair.</description><content:encoded>&lt;p&gt;Small services often accumulate recovery logic before their normal path is fully understood. Every retry, alternate endpoint, and compatibility branch adds another state that must be observed and tested.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/reliable-path.detail.en.svg" alt="The common path builds from pinned tools, validates a complete directory, publishes and verifies public responses, and retains the previous directory. Configuration, runtime and documentation agree on one selected release. Failure first asks whether it is transient and safe to repeat. Only then can retries consume an attempt-and-time budget; otherwise stop and correct. Permission errors are excluded. A lost response may already conceal success. Recovery explicitly returns to the retained known-good directory and records the outcome." width="720" height="3620"&gt;&lt;figcaption&gt;Keep the common path visible. Qualify and bound retries, then recover to a retained known-good state rather than a partial copy.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="make-the-common-path-boring"&gt;Make the common path boring&lt;/h2&gt;
&lt;p&gt;A useful default is explicit, easy to inspect, and safe to repeat. For a static site, that can be as simple as:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build from a pinned toolchain.&lt;/li&gt;
&lt;li&gt;Check the generated HTML, assets, and complete staged directory.&lt;/li&gt;
&lt;li&gt;Publish that directory, then verify public responses against it.&lt;/li&gt;
&lt;li&gt;Keep the previous directory available for rollback.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The sequence is less impressive than a multi-stage deployment system, but its state is visible. When something fails, the operator can answer whether the problem is in the source, the build, the copy, or the web server.&lt;/p&gt;
&lt;h2 id="retry-only-a-bounded-operation"&gt;Retry only a bounded operation&lt;/h2&gt;
&lt;p&gt;Retries are useful when the failure may be transient and repeating the operation preserves its intended effect. Idempotence does not mean every response or incidental log entry is identical; after a lost response, the first attempt may already have succeeded. A download from an origin may be safe to retry; a database migration may not be. Treating every error as retryable hides the boundary that needs attention.&lt;/p&gt;
&lt;p&gt;When a retry is appropriate, record the attempt count and the final reason. A log entry such as &amp;ldquo;publish failed: checksum mismatch&amp;rdquo; is more actionable than a sequence of silent retries followed by a generic timeout.&lt;/p&gt;
&lt;h2 id="prefer-one-authoritative-state"&gt;Prefer one authoritative state&lt;/h2&gt;
&lt;p&gt;Configuration, runtime state, and documentation should agree about which directory is live and which command produces it. If a release can be copied to three different paths depending on who runs it, recovery becomes guesswork.&lt;/p&gt;
&lt;p&gt;For a static deployment, a short release record is enough:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;release: 2026-07-20
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;source: git commit &amp;lt;commit-id&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;builder: Hugo 0.165.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;output: public/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;verification: links + headers + checksums
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The placeholder commit ID is deliberately resolved at release time instead of being invented in documentation.&lt;/p&gt;
&lt;h2 id="recovery-should-restore-a-known-state"&gt;Recovery should restore a known state&lt;/h2&gt;
&lt;p&gt;Recovery is easiest when it returns the service to the last known-good directory, not when it chooses among several partially built alternatives. Keep the previous output until the new output has passed its checks, then remove old copies according to a retention policy.&lt;/p&gt;
&lt;p&gt;The goal is not to eliminate failure. It is to make the failure small enough to explain and the recovery small enough to trust.&lt;/p&gt;
&lt;p&gt;Bound retries by both attempts and elapsed time. Use backoff with jitter when many clients could retry together, and avoid stacking retries at every layer. Permission errors and deterministic bad input need correction, not a longer retry loop.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, section 9.2.2 on idempotent methods and retries, checked 2026-07-20.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/resources/"&gt;Static-site release checklist&lt;/a&gt;, local companion resource.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/"&gt;AWS Builders’ Library: Timeouts, retries, and backoff with jitter&lt;/a&gt;, retry amplification, bounded retry budgets, backoff, and jitter, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A release manifest should name every published byte</title><link>https://hk.xbcatm.com/notes/a-release-manifest-should-name-every-published-byte/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-release-manifest-should-name-every-published-byte/</guid><description>Build a deterministic file manifest and compare it before and after transport.</description><content:encoded>&lt;p&gt;A static artifact is a directory, not one executable. Recording only the source commit or homepage checksum leaves gaps: a missing image, stale alias, or changed download can survive unnoticed. Enumerate every regular file in a frozen artifact before transport.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/release-manifest.detail.en.svg" alt="A frozen regular-file source is transported to a destination. Each produces an external sorted manifest; the destination list includes an illustrative extra old.js record. Both lists feed a full-record diff. A separate branch hashes the source manifest into a compact release identifier. Listed-file checking cannot detect extras, and matching bytes do not establish authorship." width="720" height="1990"&gt;&lt;figcaption&gt;Freeze writers, reject symlinks and special files, and keep both manifests outside the artifact trees. A listed-file check detects missing or changed bytes; recomputing and comparing the complete lists also detects extras. Hashing the trusted source manifest supplies a compact identifier, with separate controls for authenticity and metadata.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="generate-a-stable-list"&gt;Generate a stable list&lt;/h2&gt;
&lt;p&gt;Run this in Bash with GNU findutils and coreutils, from the directory containing &lt;code&gt;public/&lt;/code&gt;. Keep the manifest outside the measured tree. The release must contain only directories and regular files: inspect and reject symlinks or special files first, since they are outside this manifest&amp;rsquo;s contract.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/release.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; public
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;nonregular&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;find . ! -type d ! -type f -print -quit&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;test&lt;/span&gt; -z &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$nonregular&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; find . -type f -print0 &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C sort -z &lt;span class="p"&gt;|&lt;/span&gt; xargs -0 -r sha256sum --
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Sort filenames before hashing. NUL-delimited paths preserve spaces, tabs, and embedded newlines between tools; GNU &lt;code&gt;sha256sum&lt;/code&gt; escapes special names in its ordinary checkable output. Do not parse that output with &lt;code&gt;awk '{print $2}'&lt;/code&gt;: whitespace is legal in filenames. The separate assignment preserves a failed &lt;code&gt;find&lt;/code&gt; exit status; testing only its output could mistake a failed scan for an empty result. Treat any failed scan or pipeline as an incomplete manifest and do not publish it. Stop writers while measuring; these commands do not create a filesystem snapshot.&lt;/p&gt;
&lt;h2 id="verify-after-transport"&gt;Verify after transport&lt;/h2&gt;
&lt;p&gt;In one disposable Bash session on the destination, run the remaining fragments in order so the paths and &lt;code&gt;set&lt;/code&gt; options stay in scope. Set the manifest and artifact paths to their actual locations:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/path/to/release.sha256
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;artifact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/srv/www/site.new
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$artifact&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Checking listed files catches missing or changed bytes, but not extra files. Recompute the destination manifest with the same procedure and compare the complete records:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;observed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;mktemp &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.observed.XXXXXX&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;rm -f &amp;#34;$observed&amp;#34;&amp;#39;&lt;/span&gt; EXIT
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$artifact&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;nonregular&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;find . ! -type d ! -type f -print -quit&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;test&lt;/span&gt; -z &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$nonregular&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; find . -type f -print0 &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C sort -z &lt;span class="p"&gt;|&lt;/span&gt; xargs -0 -r sha256sum --
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$observed&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;diff -u &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$observed&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No diff and exit status zero means the regular-file paths and hashes agree. An extra stale file now fails too. The temporary output remains outside the artifact so the check never includes itself. Empty directories, permissions, ownership, and timestamps are not measured; validate those separately if deployment depends on them.&lt;/p&gt;
&lt;h2 id="add-an-aggregate-digest-for-release-records"&gt;Add an aggregate digest for release records&lt;/h2&gt;
&lt;p&gt;Hash the manifest itself for a compact release identifier:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sha256sum &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Its meaning depends on fixed rules: relative paths, sorting locale, algorithm, tool format, and included metadata. Keep the trusted manifest with the source commit and builder version.&lt;/p&gt;
&lt;h2 id="do-not-confuse-integrity-with-authenticity"&gt;Do not confuse integrity with authenticity&lt;/h2&gt;
&lt;p&gt;Matching SHA-256 digests are strong practical evidence of matching bytes, not proof of authorship. An attacker able to replace both artifact and manifest can make the check pass. Signing and trusted distribution are separate controls; verify only a manifest obtained through the intended trusted channel.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://www.gnu.org/software/coreutils/manual/html_node/sha2-utilities.html"&gt;GNU Coreutils: sha2 utilities&lt;/a&gt;, checksum generation and verification; reviewed through the linked upstream manual source, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/coreutils/coreutils/master/doc/coreutils.texi"&gt;GNU Coreutils manual source&lt;/a&gt;, untagged checksum escaping, &lt;code&gt;--check&lt;/code&gt;, and NUL-delimited sorting, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://man7.org/linux/man-pages/man1/sha256sum.1.html"&gt;sha256sum(1)&lt;/a&gt;, command behavior and output format, checked 2026-07-16.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://www.gnu.org/software/bash/manual/html_node/Simple-Command-Expansion.html"&gt;GNU Bash: Simple Command Expansion&lt;/a&gt;, assignment-only commands retain command-substitution exit status, checked 2026-10-18.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Redirects and error codes should preserve the story</title><link>https://hk.xbcatm.com/notes/redirects-and-error-codes-should-preserve-the-story/</link><pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/redirects-and-error-codes-should-preserve-the-story/</guid><description>Use permanent redirects and absence codes to describe what happened, not to hide a broken route.</description><content:encoded>&lt;p&gt;An HTTP status is part of the interface. A browser may render a friendly page, but a monitor, crawler, cache, and API client also need the status code and headers to tell the same story.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/redirect-map.detail.en.svg" alt="An old endpoint receives POST and returns 308 with Location; after resolving Location the client can send POST to the new endpoint. 307 and 308 preserve method while 301 and 302 may change POST to GET. Separate absence branches show 404 for temporary or unknown absence and 410 for likely permanent unavailability; neither explains the cause and a friendly page must retain the correct non-200 status. Relative Location is valid when resolved against the original URI. 308 caching depends on method and cache controls; a cached move can outlast configuration. Curl without --location does not follow." width="720" height="2960"&gt;&lt;figcaption&gt;Choose relocation or absence deliberately. Preserve the required method, resolve Location, account for caching, and keep error-page status truthful.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="permanent-moves-are-not-all-interchangeable"&gt;Permanent moves are not all interchangeable&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;308 Permanent Redirect&lt;/code&gt; communicates that the resource has a new permanent URI while preserving the request method. That is important for a client that sends &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, or another non-&lt;code&gt;GET&lt;/code&gt; method. A redirect from an old page to a new page is easy to test with &lt;code&gt;GET&lt;/code&gt;; a migration of an endpoint must also be tested with the method that real callers use.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null https://old.example.test/api/item
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without &lt;code&gt;--location&lt;/code&gt;, curl leaves the redirect unfollowed. Resolve &lt;code&gt;Location&lt;/code&gt; against the original URI: an absolute URI, root-relative path, or other relative reference can be valid. Check the resolved host, path, and scheme. &lt;code&gt;301&lt;/code&gt;/&lt;code&gt;302&lt;/code&gt; permit changing POST to GET; &lt;code&gt;307&lt;/code&gt;/&lt;code&gt;308&lt;/code&gt; preserve the method. Test state-changing methods only against an isolated endpoint. &lt;code&gt;308&lt;/code&gt; is heuristically cacheable, subject to method and cache controls, so a cached move can outlive a server configuration change.&lt;/p&gt;
&lt;p&gt;If the intent is to show a result after a completed action, &lt;code&gt;303 See Other&lt;/code&gt; points to a separate resource that the client can retrieve with GET or HEAD. It does not ask the client to replay the original write at a new address; choose that behavior deliberately rather than treating every redirect as a relocation.&lt;/p&gt;
&lt;h2 id="distinguish-missing-from-removed"&gt;Distinguish missing from removed&lt;/h2&gt;
&lt;p&gt;Use &lt;code&gt;404 Not Found&lt;/code&gt; when the server cannot find a current representation and the absence may be temporary or unknown. &lt;code&gt;410 Gone&lt;/code&gt; is stronger: it says access is no longer available and the condition is likely permanent; it does not establish why the resource disappeared. Both should have a body that helps a human, but neither should return &lt;code&gt;200&lt;/code&gt; just because a custom error page rendered successfully.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;404&lt;/code&gt; can also conceal whether a forbidden resource exists. Both &lt;code&gt;404&lt;/code&gt; and &lt;code&gt;410&lt;/code&gt; are heuristically cacheable, subject to method and cache controls. Choose an explicit policy for error responses: a cached missing-route result can remain visible after the route is repaired.&lt;/p&gt;
&lt;p&gt;The check belongs at the edge:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D missing.headers -o missing.html -w &lt;span class="s1"&gt;&amp;#39;%{http_code} %{url_effective}\n&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/a-route-that-does-not-exist
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Keep the response headers on the error path. Security headers, a useful content type, and a correct status are more valuable than a decorative error animation.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, sections 15 and 13, checked 2026-07-14.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7538.html"&gt;RFC 7538 — 308 Permanent Redirect&lt;/a&gt;, section 3, checked 2026-07-14.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, status and redirect inspection flags, checked 2026-07-14.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Read the public edge in two commands</title><link>https://hk.xbcatm.com/notes/read-the-public-edge-in-two-commands/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/read-the-public-edge-in-two-commands/</guid><description>Read response headers and certificate dates while keeping expiry, identity, and byte verification distinct.</description><content:encoded>&lt;p&gt;A deployment is complete when public responses match the release contract, not when the last upload exits zero. Two read-only helpers from the &lt;a href="https://hk.xbcatm.com/resources/"&gt;operator kit&lt;/a&gt; expose useful evidence, but they do not prove the whole artifact was published correctly.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/field-output.detail.en.svg" alt="Illustrative source excerpts show HTTP_STATUS 200 and FINAL_URL, then a full 2026 certificate expiry timestamp and a 30-day expiry-only result. Annotations distinguish shell success from HTTP status and expiry from notBefore, hostname and trust checks. Body comparison and SAN identity policy remain separate." width="720" height="1970"&gt;&lt;figcaption&gt;These are illustrative excerpts. Evaluate HTTP status and expiry separately; verify hostname, trust and body equality with the checks that actually test them.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="inspect-the-response-headers"&gt;Inspect the response headers&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;check-http-headers.sh&lt;/code&gt; performs a GET, discards the body, follows redirects, and prints the response headers for each hop plus the final status and URL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;check-http-headers.sh https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This illustrative output shows the fields to inspect. It is not a fresh production observation:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HTTP/2 200
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;accept-ranges: bytes
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;content-security-policy: default-src &amp;#39;self&amp;#39;; base-uri &amp;#39;self&amp;#39;; form-action &amp;#39;none&amp;#39;; frame-ancestors &amp;#39;none&amp;#39;; object-src &amp;#39;none&amp;#39;; script-src &amp;#39;none&amp;#39;; style-src &amp;#39;self&amp;#39;; img-src &amp;#39;self&amp;#39; data:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;content-type: text/html; charset=utf-8
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;last-modified: Mon, 24 Aug 2026 11:20:54 GMT
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;permissions-policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;referrer-policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;strict-transport-security: max-age=31536000; includeSubDomains
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;x-content-type-options: nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;x-frame-options: DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HTTP_STATUS 200
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;FINAL_URL https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Check the expected status, final host and path, and the policy on the final response. The helper does not use curl&amp;rsquo;s &lt;code&gt;--fail&lt;/code&gt;: an HTTP 404 or 500 can still leave the shell command successful. Treat its printed status as data to evaluate. A header mismatch warrants investigation of server, proxy, or cache configuration; it does not by itself prove the wrong output directory is live.&lt;/p&gt;
&lt;h2 id="inspect-the-certificate-dates"&gt;Inspect the certificate dates&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;check-tls-expiry.sh HOST [DAYS]&lt;/code&gt; extracts the certificate presented with SNI and checks whether its expiry is at least the requested number of seconds away:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;check-tls-expiry.sh www.xbcatm.com &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An illustrative excerpt, with the fingerprint line omitted:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;subject=CN=www.xbcatm.com
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;issuer=C=US, O=Let&amp;#39;s Encrypt, CN=YE1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;notBefore=Aug 21 08:37:13 2026 GMT
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;notAfter=Nov 19 08:37:12 2026 GMT
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;certificate remains valid for at least 30 day(s)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The helper prints subject, issuer, dates, and SHA-256 fingerprint. It does not print the remaining day count. Its success message describes the expiry test only: the script does not enforce hostname or trust-chain validation, and &lt;code&gt;-checkend&lt;/code&gt; does not validate &lt;code&gt;notBefore&lt;/code&gt;. The subject CN is not sufficient hostname evidence; service identity is matched against the certificate&amp;rsquo;s subject alternative names. A valid wildcard may legitimately cover the host.&lt;/p&gt;
&lt;p&gt;Use a separate verification when those properties matter:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;openssl s_client -connect www.xbcatm.com:443 -servername www.xbcatm.com &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -verify_hostname www.xbcatm.com -verify_return_error &amp;lt;/dev/null
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This uses the configured trust store and fails on certificate verification errors under OpenSSL&amp;rsquo;s matching rules. In OpenSSL 3.0, hostname verification can fall back to the subject CN when no suitable DNS subject alternative name is present. A successful command therefore does not by itself establish RFC 9525&amp;rsquo;s SAN-only identity policy: inspect the SAN extension and verify the intended client&amp;rsquo;s policy separately. Keep a job-level timeout around the TLS helper, which does not impose its own network deadline. Certificate inspection and automatic renewal configuration remain separate checks.&lt;/p&gt;
&lt;h2 id="use-both-around-a-release"&gt;Use both around a release&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Before: save the expected policy, final URL, validators, and certificate dates with a UTC collection time.&lt;/li&gt;
&lt;li&gt;Deploy the already validated artifact.&lt;/li&gt;
&lt;li&gt;After: repeat the probes and fetch representative bodies for comparison with that artifact.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Headers and certificate dates cannot establish byte equality. &lt;code&gt;Last-Modified&lt;/code&gt; may stay unchanged or be rewritten during copying. The &lt;a href="https://hk.xbcatm.com/notes/a-release-is-not-complete-until-the-public-edge-agrees/"&gt;release smoke test&lt;/a&gt; saves bodies and compares them directly; a complete manifest extends that check beyond one page. Browser behavior, geographic edge differences, and load-dependent failures need separate tests. Record which paths and observation points were actually checked.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, GET, redirect, header output, and HTTP failure behavior, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/"&gt;OpenSSL &lt;code&gt;s_client&lt;/code&gt;&lt;/a&gt;, SNI, explicit hostname checks, and failure on verification errors, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-verification-options/"&gt;OpenSSL verification options&lt;/a&gt;, hostname matching and subject-CN fallback, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-x509/"&gt;OpenSSL &lt;code&gt;x509&lt;/code&gt;&lt;/a&gt;, certificate display and expiry-only &lt;code&gt;-checkend&lt;/code&gt; behavior, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9525.html"&gt;RFC 9525 — Service Identity in TLS&lt;/a&gt;, subject alternative names and wildcard identity matching, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy Automatic HTTPS&lt;/a&gt;, automatic certificate management and renewal configuration, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;MDN: Content Security Policy guide&lt;/a&gt;, CSP directive background, checked 2026-07-13.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, response metadata and validators do not establish a complete artifact match, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A release is not complete until the public edge agrees</title><link>https://hk.xbcatm.com/notes/a-release-is-not-complete-until-the-public-edge-agrees/</link><pubDate>Thu, 09 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-release-is-not-complete-until-the-public-edge-agrees/</guid><description>Compare the generated artifact with the responses a visitor can actually receive.</description><content:encoded>&lt;p&gt;Static publishing removes application runtime complexity, but it does not remove the boundary between a generated directory and a public response. A release check should cover both.&lt;/p&gt;
&lt;p&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/diagrams/release-path.svg" alt="A small static release path" /&gt;
&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/release-edge.detail.en.svg" alt="Check the exact artifact at a staged server with intended configuration, then repeat at the public edge after switching directories. Pair index.html, the downloadable resource, and 404.html with decoded GET bodies. Compare their bytes separately from expected status, type, cache, and security headers. Repeat English and Chinese samples and retain the previous known-good directory until the new one passes." width="720" height="2200"&gt;&lt;figcaption&gt;Use the same artifact before and after selection. The download row refers to public/downloads/check-http-headers.sh in the article. Supported content codings are decoded before cmp; server transformations need a documented rule. Status and header checks are separate. Retain the previous directory for a deliberate rollback.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="start-from-a-clean-artifact"&gt;Start from a clean artifact&lt;/h2&gt;
&lt;p&gt;Build with the pinned generator into a clean destination. Record the source commit, generator version, output file list, and checksums of files that users download. A clean build catches stale files that a copy-over deployment can accidentally preserve.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make verify
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;find public -type f -print &lt;span class="p"&gt;|&lt;/span&gt; sort
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file list is evidence, not decoration. If a route disappears, the diff should explain whether the source was removed or the generator failed to emit it.&lt;/p&gt;
&lt;h2 id="exercise-three-response-classes"&gt;Exercise three response classes&lt;/h2&gt;
&lt;p&gt;Before publishing, test the staged artifact with the intended server configuration. After switching directories, repeat these checks at the public edge; probing the old live site cannot validate the new release. Request one HTML page, one static resource, and one missing path. Inspect status, content type, cache policy, and security headers:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --compressed -fsS -D page.headers -o page.body https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --compressed -fsS -D asset.headers -o asset.body &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/downloads/check-http-headers.sh
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --compressed -sS -D missing.headers -o missing.body &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/does-not-exist
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp public/index.html page.body
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp public/downloads/check-http-headers.sh asset.body
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp public/404.html missing.body
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run each fragment as a standalone shell script or in a disposable shell session. &lt;code&gt;set -eu&lt;/code&gt; stops this sequential script after a failed build, transfer, or comparison, so a later successful command cannot hide an earlier failure. The missing-path request intentionally does not use &lt;code&gt;-f&lt;/code&gt;: a 404 is expected and should still be captured. Check the final status in each header file: &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;200&lt;/code&gt;, then &lt;code&gt;404&lt;/code&gt;; the byte comparisons do not automate that status check. No redirect is followed here. &lt;code&gt;--compressed&lt;/code&gt; decodes supported content codings before comparison; a deliberate server-side HTML transformation needs its own documented comparison rule. &lt;code&gt;cmp&lt;/code&gt; is silent on equality and returns nonzero on a difference. Repeat for &lt;code&gt;/zh/&lt;/code&gt; and its error page rather than assuming language parity.&lt;/p&gt;
&lt;h2 id="keep-rollback-simple"&gt;Keep rollback simple&lt;/h2&gt;
&lt;p&gt;Do not delete the previous directory until the new one has passed the checks. A rollback should select a known-good directory, not reconstruct one from memory during an incident. The &lt;a href="https://hk.xbcatm.com/resources/"&gt;release checklist&lt;/a&gt; and &lt;a href="https://hk.xbcatm.com/resources/"&gt;HTTP header check&lt;/a&gt; are deliberately small enough to run from a clean shell.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, status and representation semantics, checked 2026-07-09.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, response inspection options, checked 2026-07-09.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/resources/"&gt;Static-site release checklist&lt;/a&gt;, local companion resource.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A postmortem should leave a fact record</title><link>https://hk.xbcatm.com/notes/a-postmortem-should-leave-a-fact-record/</link><pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-postmortem-should-leave-a-fact-record/</guid><description>Structure an incident write-up around captured output so the review thread stays on evidence instead of memory.</description><content:encoded>&lt;p&gt;A postmortem earns trust when its claims can be re-checked. Record each observation’s source, collection time and timezone, command or query, and exit status where relevant. Logs, metrics, and clearly attributed human observations also count; distinguish confirmed facts from hypotheses and preserve redacted evidence rather than secrets. The four phases below keep a write-up reviewable at any later time.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/postmortem.detail.en.svg" alt="Four entries on a vertical incident timeline attach captures to Detect, tested boundaries to Scope, a configuration diff and intended result to Fix, and repeat evidence to Verify. Each entry preserves source, collection time and context. A dashed uncertainty box is separate from an owned preventive action with due date and acceptance check." width="720" height="2860"&gt;&lt;figcaption&gt;Tie every phase to captured evidence. A post-fix sample is a bounded observation; causes, uncertainty and owned prevention still need explicit treatment.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="detect-the-symptom-with-evidence"&gt;Detect: the symptom, with evidence&lt;/h2&gt;
&lt;p&gt;Start with the smallest verifiable symptom and the command that showed it. Save the actual capture. The following is an illustrative excerpt, not evidence of a fresh probe:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ check-http-headers.sh https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HTTP/2 200
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;content-security-policy: ... script-src &amp;#39;none&amp;#39; ...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;last-modified: Mon, 24 Aug 2026 11:20:54 GMT
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the symptom is that a header changed, the output showing the previous policy and the new one belongs side by side. If it is a failure, include the exit status and the error line.&lt;/p&gt;
&lt;h2 id="scope-how-wide-and-how-you-know"&gt;Scope: how wide, and how you know&lt;/h2&gt;
&lt;p&gt;State the blast radius with evidence, not adjectives. Define the boundary you can inspect:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which hosts and paths show the symptom? Record each probed URL and observation time; one successful request cannot establish the state of every edge or visitor.&lt;/li&gt;
&lt;li&gt;Does the cache differ from origin? Compare actual responses, cache metadata, and validators from both paths using the same request headers. &lt;code&gt;Vary&lt;/code&gt; describes selection inputs, not proof that the response came from cache.&lt;/li&gt;
&lt;li&gt;What delays recovery? For a DNS change, record the old TTL and resolver observations. For HTTP caches, record freshness and revalidation policy separately; a DNS TTL is not a universal rollback deadline.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="fix-the-change-and-its-intended-effect"&gt;Fix: the change and its intended effect&lt;/h2&gt;
&lt;p&gt;Record the actual configuration diff and deployment reference so the corrective action can be understood and, where safe, replayed. This header fragment is an expected observation, not a shell command or a complete policy. Prefer the smallest change that restores the contract, and name what the post-fix run should show:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Content-Security-Policy: script-src &amp;#39;none&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="verify-the-post-incident-run"&gt;Verify: the post-incident run&lt;/h2&gt;
&lt;p&gt;Repeat the detection command after the change and show that the observed output now matches the intended contract. A postmortem without this final section is a story; with it, it is a record that approves its own future re-check.&lt;/p&gt;
&lt;p&gt;Close the record with contributing causes, what remains uncertain, and preventive actions with an owner, due date, and acceptance check. A successful probe confirms that sampled path at that time; it does not establish the cause or prevent recurrence by itself.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, response metadata and conditional requests, checked 2026-07-07.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/resources/"&gt;artifact HTTP header check&lt;/a&gt;, read-only inspection used throughout this record.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sre.google/sre-book/postmortem-culture/"&gt;Google SRE: Postmortem Culture&lt;/a&gt;, impact, contributing causes, evidence review, and preventive actions, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111.html"&gt;RFC 9111 — HTTP Caching&lt;/a&gt;, cache selection and freshness are distinct from DNS cache lifetimes, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Range requests should prove which bytes were selected</title><link>https://hk.xbcatm.com/notes/range-requests-should-prove-which-bytes-were-selected/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/range-requests-should-prove-which-bytes-were-selected/</guid><description>Verify partial responses, Content-Range, and the behavior of unsatisfiable byte ranges.</description><content:encoded>&lt;p&gt;Range requests let a client ask for part of a representation. Media players, resumable download tools, and diagnostic clients use them, but a server that advertises or returns ranges incorrectly can produce silent corruption instead of a clean failure.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/range-request.detail.en.svg" alt="A stable encoded representation of N bytes supplies the reference size. A ruler selects inclusive offsets 0 through 15, exactly 16 bytes. Outcomes distinguish matching 206 bytes and Content-Range, complete 200 when Range is ignored, and rejected unsatisfiable ranges starting at offset N with 416 and bytes */N. Strong If-Range validators prevent splicing different versions. Weak ETags are forbidden; a strong HTTP date qualifies only when no ETag exists. Changed representation requires a complete current body." width="720" height="3050"&gt;&lt;figcaption&gt;Keep the representation and content coding stable. Match interval, total size and actual body bytes; a complete 200 can be valid when Range is ignored. Resume only with an appropriate strong validator.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="establish-the-complete-size"&gt;Establish the complete size&lt;/h2&gt;
&lt;p&gt;Use an immutable test download of at least 16 bytes. Run the fragments in the same disposable shell session so &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;size&lt;/code&gt; remain available. Their explicit &lt;code&gt;set -eu&lt;/code&gt; stops after a failed transfer, size check, or comparison; inspect the HTTP status and range fields separately. Save the complete body, headers, and validator in that temporary working directory:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://www.example.test/downloads/archive.tar.gz
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -fsS -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; -D full.headers -o full.body &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;wc -c &amp;lt; full.body&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -ge &lt;span class="m"&gt;16&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;awk &lt;span class="s1"&gt;&amp;#39;tolower($1) ~ /^(content-length:|content-encoding:|etag:|last-modified:|accept-ranges:)$/ { print }&amp;#39;&lt;/span&gt; full.headers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Check that the full response is &lt;code&gt;200&lt;/code&gt; and has no unexpected content coding. The commands request the identity representation and do not use automatic decompression; byte ranges apply to the selected encoded representation, so changing encoding invalidates the comparison. &lt;code&gt;Content-Length&lt;/code&gt; may be absent, which is why the complete body supplies the size.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Accept-Ranges: bytes&lt;/code&gt; is an explicit signal, but absence of the header does not by itself prove that range requests are unsupported. Exercise the request.&lt;/p&gt;
&lt;h2 id="request-a-known-interval"&gt;Request a known interval&lt;/h2&gt;
&lt;p&gt;Ask for the first 16 bytes and keep both headers and body:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D range.headers -o range.body &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; -H &lt;span class="s1"&gt;&amp;#39;Range: bytes=0-15&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wc -c range.body
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;head -c &lt;span class="m"&gt;16&lt;/span&gt; full.body &amp;gt; expected.body
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp expected.body range.body
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;awk &lt;span class="s1"&gt;&amp;#39;tolower($1) ~ /^(content-range:|content-length:|etag:)$/ || /^HTTP\// { print }&amp;#39;&lt;/span&gt; range.headers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A successful byte-range response uses &lt;code&gt;206 Partial Content&lt;/code&gt;. &lt;code&gt;Content-Range&lt;/code&gt; should identify &lt;code&gt;0-15&lt;/code&gt; and the complete representation length, while the body should contain exactly 16 bytes.&lt;/p&gt;
&lt;p&gt;The byte comparison deliberately tests the partial-body case. A valid full &lt;code&gt;200&lt;/code&gt; larger than 16 bytes will fail it and stop this shell session; inspect the saved headers before calling that corruption. Conversely, a complete 16-byte file can pass &lt;code&gt;cmp&lt;/code&gt; even with &lt;code&gt;200&lt;/code&gt;, so status and &lt;code&gt;Content-Range&lt;/code&gt; still need checking.&lt;/p&gt;
&lt;h2 id="test-an-impossible-range"&gt;Test an impossible range&lt;/h2&gt;
&lt;p&gt;Start at the complete byte count: for a file of size N, the last valid offset is N−1. Unlike a fixed large constant, this is outside the known representation:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;Range: bytes=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;When the server rejects an unsatisfiable byte range with &lt;code&gt;416 Range Not Satisfiable&lt;/code&gt;, it should include &lt;code&gt;Content-Range&lt;/code&gt;, for example &lt;code&gt;bytes */12345&lt;/code&gt;, to report the current size. A &lt;code&gt;200&lt;/code&gt; response may be valid when the server ignores the range, but it must return the complete representation rather than a mislabeled fragment.&lt;/p&gt;
&lt;h2 id="keep-validators-in-the-story"&gt;Keep validators in the story&lt;/h2&gt;
&lt;p&gt;Resumed downloads can use &lt;code&gt;If-Range&lt;/code&gt; with a strong ETag. A weak ETag is forbidden; an HTTP date is allowed only when no ETag is available and the date qualifies as a strong validator. If the representation changed, the safe response is the full current representation, not bytes spliced from two versions. Test this behavior before calling a download resumable.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-range-requests"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, range units, partial responses, and &lt;code&gt;Content-Range&lt;/code&gt;, checked 2026-07-06.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, &lt;code&gt;--range&lt;/code&gt; and header options, checked 2026-07-06.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>HTTP methods are a promise about side effects</title><link>https://hk.xbcatm.com/notes/http-methods-are-a-promise-about-side-effects/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/http-methods-are-a-promise-about-side-effects/</guid><description>The HTTP method names what a request may change; a static origin can answer most of them without pretending to have a stateful contract.</description><content:encoded>&lt;p&gt;A request method tells the server what the client is asking it to do. A read-only file origin has a narrow contract, but “static site” alone does not prove that proxies or imported handlers expose no write operations. Check the route actually serving the resource.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/http-methods.detail.en.svg" alt="A semantic matrix separates safe GET and HEAD from unsafe but idempotent PUT and DELETE. A scoped Caddy v2.10.2 file route accepts GET and HEAD and rejects other methods with 405 and Allow, while earlier handlers may differ. Safe methods can still produce incidental logs; idempotency does not promise identical statuses or authorize every retry. Any supplied HEAD Content-Length describes the corresponding GET with the same coding; omission may be legal. Method support is separate from CORS permission." width="720" height="2900"&gt;&lt;figcaption&gt;Separate requested state change from intended repeat effect, then inspect the actual route. The file-handler boundary is scoped; method support is separate from CORS permission.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="safe-and-idempotent-answer-different-questions"&gt;Safe and idempotent answer different questions&lt;/h2&gt;
&lt;p&gt;GET and HEAD are safe: the client does not request a state-changing action. Incidental logging, metrics, or accounting can still change server state. PUT and DELETE are idempotent in their defined semantics, but are not safe: repeating the same request is intended to have the same effect as performing it once. That does not require identical response statuses, and it does not make every retry after a network failure appropriate.&lt;/p&gt;
&lt;p&gt;For a read-only static origin:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GET&lt;/strong&gt; retrieves a representation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HEAD&lt;/strong&gt; describes the corresponding GET without response content.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OPTIONS&lt;/strong&gt; asks about the target’s communication options; a file handler may reject it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;POST / PUT / DELETE / PATCH&lt;/strong&gt; should not fall through to successful file serving unless a separate, intended handler implements their semantics.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="a-caddy-file-handlers-method-boundary"&gt;A Caddy file handler’s method boundary&lt;/h2&gt;
&lt;p&gt;Caddy v2.10.2’s file-serving path accepts GET and HEAD and rejects other methods with &lt;code&gt;405&lt;/code&gt; and &lt;code&gt;Allow: GET, HEAD&lt;/code&gt;. Redirects, missing paths, error handlers, and other configured routes can answer earlier or differently, so this is not a universal response for every static-site URL.&lt;/p&gt;
&lt;p&gt;The earlier note recorded:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ curl -s -o /dev/null -w &amp;#39;OPTIONS -&amp;gt; HTTP %{http_code}\n&amp;#39; -X OPTIONS https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OPTIONS -&amp;gt; HTTP 405
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The original response log is not retained here, and this review did not reproduce that live result. The command reports only status; it does not establish that the required &lt;code&gt;Allow&lt;/code&gt; field was present. Inspect that field separately. &lt;code&gt;405&lt;/code&gt; means a known method is not supported for this target; &lt;code&gt;501 Not Implemented&lt;/code&gt; is the usual distinction when the server does not recognize or implement a method at all.&lt;/p&gt;
&lt;h2 id="head-parity-allows-documented-omissions"&gt;HEAD parity allows documented omissions&lt;/h2&gt;
&lt;p&gt;HEAD should describe the same selected representation as GET, while omitting content. Fields calculated only while generating the content may be omitted. If &lt;code&gt;Content-Length&lt;/code&gt; is present, it must describe the corresponding GET content, using the same content coding.&lt;/p&gt;
&lt;p&gt;The earlier note reported this comparison:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;HEAD content-length: 15890
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET bytes read : 15890
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Those historical numbers are not a fixed expected size or a current verification result. A missing HEAD length alone is legal; a wrong supplied length needs investigation. Follow the &lt;code&gt;head-request-parity&lt;/code&gt; note for paired probes. The downloadable &lt;code&gt;check-http-headers.sh&lt;/code&gt; helper inspects a GET response and does not perform this comparison automatically.&lt;/p&gt;
&lt;h2 id="reject-unintended-writes-without-hiding-the-contract"&gt;Reject unintended writes without hiding the contract&lt;/h2&gt;
&lt;p&gt;For a method recognized but disallowed on the target, return &lt;code&gt;405&lt;/code&gt; with &lt;code&gt;Allow&lt;/code&gt; listing the methods actually supported. The list is not necessarily restricted to safe methods on an application route. Do not return a plausible &lt;code&gt;200&lt;/code&gt; page for an ignored write request.&lt;/p&gt;
&lt;p&gt;If browser clients need CORS preflight, define that OPTIONS behavior explicitly; method support and CORS permission are separate decisions. Test write rejection only against an authorized disposable fixture rather than assuming a public endpoint is harmless because the site looks static.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, safe/idempotent methods and HEAD/405/501 semantics, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods"&gt;MDN: HTTP request methods&lt;/a&gt;, method overview, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, method and response-inspection options, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/caddyserver/caddy/v2.10.2/modules/caddyhttp/fileserver/staticfiles.go"&gt;Caddy v2.10.2 file-server source&lt;/a&gt;, method rejection after file selection, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fetch.spec.whatwg.org/#cors-preflight-fetch"&gt;WHATWG Fetch Standard&lt;/a&gt;, CORS preflight processing, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Validators decide whether a cached copy is still the answer</title><link>https://hk.xbcatm.com/notes/validators-decide-whether-a-cached-copy-is-still-the-answer/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/validators-decide-whether-a-cached-copy-is-still-the-answer/</guid><description>ETags and Last-Modified let caches check whether a stored copy is current and avoid transferring the body when revalidation confirms it is unchanged.</description><content:encoded>&lt;p&gt;A cache can ask whether its stored copy is still usable without always downloading the body again. It sends a validator; a matching conditional GET can receive &lt;code&gt;304 Not Modified&lt;/code&gt;, while a changed representation normally needs a new &lt;code&gt;200&lt;/code&gt; response. Validation saves bytes on a hit, not on every request.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/etag-validation.detail.en.svg" alt='The cache sends the exact weak ETag W/"release-a" in If-None-Match. Weak comparison matches the origin tag "release-a", giving a body-free 304; the cache updates metadata and reuses stored body A. Alternatively, origin tag "release-b" produces 200 and new body B for GET. HEAD uses the same status test but returns no body. Weak matching implies neither byte identity nor freshness.' width="720" height="1750"&gt;&lt;figcaption&gt;Illustrative tags; otherwise successful requests. GET/HEAD use weak comparison. HEAD has no body. Matching is neither byte identity nor freshness. Other methods normally return 412; If-Range cannot use weak ETags.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="validator-type-and-strength-are-different-questions"&gt;Validator type and strength are different questions&lt;/h2&gt;
&lt;p&gt;An &lt;code&gt;ETag&lt;/code&gt; is an opaque, quoted value. It can be strong (&lt;code&gt;&amp;quot;release-a&amp;quot;&lt;/code&gt;) or explicitly weak (&lt;code&gt;W/&amp;quot;release-a&amp;quot;&lt;/code&gt;). Strong comparison requires both tags to be strong and equal; weak comparison compares the opaque values regardless of the &lt;code&gt;W/&lt;/code&gt; marker. Weak tags can describe semantic equivalence even when bytes differ.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Last-Modified&lt;/code&gt; is a timestamp with one-second HTTP-date resolution. It is usually weak unless the conditions in RFC 9110 section 8.8.2.2 establish it as strong. Do not classify every ETag as strong or every timestamp as unconditionally weak.&lt;/p&gt;
&lt;p&gt;The earlier site observation recorded:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;etag: &amp;#34;dkx84jwth7nkc9e&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;last-modified: Mon, 24 Aug 2026 13:55:23 GMT
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These are historical values; the original response log is not retained here and the live result was not rechecked in this review. Caddy’s file server normally sets both fields, subject to file metadata and configuration.&lt;/p&gt;
&lt;h2 id="conditional-get-uses-weak-comparison"&gt;Conditional GET uses weak comparison&lt;/h2&gt;
&lt;p&gt;Send the ETag exactly as received in &lt;code&gt;If-None-Match&lt;/code&gt;, including its quotes and any &lt;code&gt;W/&lt;/code&gt;. The original note reported &lt;code&gt;HTTP 304&lt;/code&gt; for the value above but printed an unquoted request value. That request text was not valid entity-tag syntax and should not be copied as a working probe. Obtain a current validator before testing a live endpoint.&lt;/p&gt;
&lt;p&gt;This socket-free exercise illustrates the comparison for individual, already-parsed tags; it is not a complete HTTP header parser:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weak_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;W/&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strong_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;W/&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;W/&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;W/&amp;#34;release-a&amp;#34;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#34;release-a&amp;#34;&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;weak_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;weak_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;strong_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;weak_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;weak_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#34;release-b&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;weak match: True; strong match: False; changed tag: no match&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For an otherwise successful GET or HEAD, a matching &lt;code&gt;If-None-Match&lt;/code&gt; produces a body-free &lt;code&gt;304&lt;/code&gt;. The same false precondition on another method normally produces &lt;code&gt;412 Precondition Failed&lt;/code&gt;. If both &lt;code&gt;If-None-Match&lt;/code&gt; and &lt;code&gt;If-Modified-Since&lt;/code&gt; are present, the ETag condition takes precedence. Byte-range validation with &lt;code&gt;If-Range&lt;/code&gt; cannot use a weak ETag.&lt;/p&gt;
&lt;h2 id="republish-without-preserving-a-misleading-validator"&gt;Republish without preserving a misleading validator&lt;/h2&gt;
&lt;p&gt;An ETag is not necessarily a content hash. Caddy v2.10.2’s default file tag is derived from modification time and size; replacing bytes while preserving both can preserve the tag. Inspect the deployed version and any configured overrides, then make the release process update the validator’s inputs or use content-based versioning.&lt;/p&gt;
&lt;p&gt;Keep byte checksums in the release manifest and test both unchanged and changed responses. A checksum does not automatically become the server’s ETag. A &lt;code&gt;304&lt;/code&gt; also needs the applicable cache metadata so a cache can update its stored response; it is not merely an empty success status.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, sections 8.8 and 13 on validator strength and preconditions, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111.html"&gt;RFC 9111 — HTTP Caching&lt;/a&gt;, section 4.3 on validation and updating stored responses, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Conditional_requests"&gt;MDN: HTTP conditional requests&lt;/a&gt;, conditional-request overview, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://raw.githubusercontent.com/caddyserver/caddy/v2.10.2/modules/caddyhttp/fileserver/staticfiles.go"&gt;Caddy v2.10.2 file-server source&lt;/a&gt;, &lt;code&gt;calculateEtag&lt;/code&gt; inputs and default response fields, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Compression changes the selected HTTP representation</title><link>https://hk.xbcatm.com/notes/compression-changes-the-selected-http-representation/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/compression-changes-the-selected-http-representation/</guid><description>Treat content encoding, validators, and Vary as one observable response contract.</description><content:encoded>&lt;p&gt;Compression saves transfer bytes, but it also creates another representation of a resource. A client announces acceptable content codings with &lt;code&gt;Accept-Encoding&lt;/code&gt;; the server describes the applied coding with &lt;code&gt;Content-Encoding&lt;/code&gt;.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/compression.detail.en.svg" alt="A negotiation fork selects identity bytes A or encoded bytes B, requiring different strong tags. Vary controls matching; weak tags have a qualified alternative. A separate pipeline keeps encoded Content-Length in saved response headers while curl decodes the body before an artifact-checksum comparison." width="720" height="2320"&gt;&lt;figcaption&gt;Compression selects representation bytes, so negotiation, cache matching, and validators must agree. Distinct coded bytes need distinct strong tags. Compare the decoded download with the artifact; an encoded Content-Length need not equal the decoded file size.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="make-negotiation-visible"&gt;Make negotiation visible&lt;/h2&gt;
&lt;p&gt;When a response can change according to &lt;code&gt;Accept-Encoding&lt;/code&gt;, include that request field in &lt;code&gt;Vary&lt;/code&gt;. This tells shared caches that a compressed response and an identity response are not interchangeable cache entries.&lt;/p&gt;
&lt;p&gt;Compare the two paths instead of checking only a browser request:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS --compressed -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Inspect &lt;code&gt;Content-Encoding&lt;/code&gt;, &lt;code&gt;Vary&lt;/code&gt;, &lt;code&gt;Content-Length&lt;/code&gt; when present, and the validator. Strong entity tags must differ when content coding changes the representation bytes. A shared weak tag is possible for semantically equivalent variants, but it cannot support strong comparison. Check conditionals against the same negotiated representation.&lt;/p&gt;
&lt;h2 id="compress-appropriate-content"&gt;Compress appropriate content&lt;/h2&gt;
&lt;p&gt;Text formats such as HTML, CSS, JSON, XML, and SVG usually benefit from compression. Formats that are already compressed might not. Caddy&amp;rsquo;s &lt;code&gt;encode&lt;/code&gt; directive negotiates supported encodings and normally avoids encoding a response without an appropriate content type or minimum size.&lt;/p&gt;
&lt;p&gt;Do not add compression solely to satisfy a score. Measure a representative response, confirm that the edge emits the intended headers, and verify that downloadable files retain the correct content type and checksum after transfer decoding.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;--compressed&lt;/code&gt;, curl decodes the body automatically but leaves saved response headers unchanged. A reported &lt;code&gt;Content-Length&lt;/code&gt;, when present, describes encoded content, so it need not equal the size of the decoded download. Compare a checksum with the artifact only after content decoding; HTTP transfer framing is a separate layer. The option also does not guarantee compression: inspect the actual response, especially for small or already-compressed files.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, content codings and &lt;code&gt;Vary&lt;/code&gt;, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/encode"&gt;Caddy &lt;code&gt;encode&lt;/code&gt; directive&lt;/a&gt;, content encoding selection and negotiation, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, &lt;code&gt;--compressed&lt;/code&gt;, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Cache headers are a contract with two clocks</title><link>https://hk.xbcatm.com/notes/cache-headers-are-a-contract-with-two-clocks/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/cache-headers-are-a-contract-with-two-clocks/</guid><description>Choose freshness and revalidation rules from how often a representation can change.</description><content:encoded>&lt;p&gt;Caching is not a single switch. A response has a freshness decision made by a cache and a validation conversation with the origin. Mixing those two ideas produces either stale content or needless requests.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/cache-clocks.detail.en.svg" alt="A storage and reuse matrix is followed by stable and fingerprinted URL policies. A conditional GET sends the real ETag in If-None-Match: a weak match can return 304, while a changed validator normally returns a new 200. Weak equivalence need not mean equal bytes. Age, Vary, freshness-limited immutable, and previously cached lifetimes constrain reuse." width="720" height="2800"&gt;&lt;figcaption&gt;Storage, freshness, and validation answer separate questions. Match the policy to how the URL changes, preserve validators exactly, and check shared-cache variants.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="pick-the-change-model-first"&gt;Pick the change model first&lt;/h2&gt;
&lt;p&gt;For an HTML document that may change after publication, &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; is usually a more accurate starting point than &lt;code&gt;no-store&lt;/code&gt;. &lt;code&gt;no-cache&lt;/code&gt; permits storage but requires a cache to validate before reuse; &lt;code&gt;no-store&lt;/code&gt; asks caches not to store the response at all. The distinction matters when you want conditional requests and a cheap &lt;code&gt;304 Not Modified&lt;/code&gt; response.&lt;/p&gt;
&lt;p&gt;For a fingerprinted asset such as &lt;code&gt;app.4f2c.js&lt;/code&gt;, the URL changes when the bytes change. A long freshness lifetime can then be reasonable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Cache-Control: public, max-age=31536000, immutable
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That policy is unsafe for a stable URL whose contents can be replaced in place. If the URL is stable, prefer a shorter lifetime and a validator such as an entity tag (&lt;code&gt;ETag&lt;/code&gt;) or a meaningful &lt;code&gt;Last-Modified&lt;/code&gt; value.&lt;/p&gt;
&lt;p&gt;Choose who may store the response as well as how long it stays fresh. Use the unqualified &lt;code&gt;private&lt;/code&gt; directive when shared caches must not store user-specific content; a browser’s private cache can still store it. &lt;code&gt;no-cache&lt;/code&gt; alone does not provide that restriction. The &lt;code&gt;public&lt;/code&gt; example above is for assets safe to share across users, not a default for personalized responses.&lt;/p&gt;
&lt;h2 id="inspect-both-paths"&gt;Inspect both paths&lt;/h2&gt;
&lt;p&gt;Make one ordinary request and one conditional request. The exact server flags vary, but the evidence you want is stable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;If-None-Match: &amp;#34;copy-a-real-etag-from-the-first-response&amp;#34;&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Do not paste a made-up validator into a runbook. Copy the value from the first response or use a test fixture. For this conditional GET, a matching &lt;code&gt;If-None-Match&lt;/code&gt; can yield &lt;code&gt;304&lt;/code&gt; using weak comparison: it permits semantic equivalence, not necessarily byte identity. A changed validator normally produces a new &lt;code&gt;200&lt;/code&gt; response. Keep the ETag’s quotes and any &lt;code&gt;W/&lt;/code&gt; prefix intact.&lt;/p&gt;
&lt;h2 id="keep-intermediaries-visible"&gt;Keep intermediaries visible&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Age&lt;/code&gt;, &lt;code&gt;Vary&lt;/code&gt;, and the cache-control directives explain why two clients can receive different results. If a response varies by &lt;code&gt;Accept-Encoding&lt;/code&gt;, language, or another request field, that dimension belongs in &lt;code&gt;Vary&lt;/code&gt;; otherwise a shared cache can reuse the wrong representation.&lt;/p&gt;
&lt;p&gt;The useful release check is not “the scanner found a cache header”. It is “the header matches the replacement policy, and a conditional request behaves as documented”.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;immutable&lt;/code&gt; applies during the advertised freshness lifetime; it is not a permanent ban on revalidation. Keep versioned URLs immutable and older assets reachable while old HTML can still reference them. Changing today’s cache header does not retroactively shorten a previously cached response’s lifetime, so a cache-policy mistake may require a new URL or an explicitly supported cache purge.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111.html"&gt;RFC 9111 — HTTP Caching&lt;/a&gt;, sections 4–5, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, sections 8 and 13, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control"&gt;MDN: &lt;code&gt;Cache-Control&lt;/code&gt;&lt;/a&gt;, directive semantics and defaults, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8246.html"&gt;RFC 8246 — Immutable Responses&lt;/a&gt;, freshness-limited immutable semantics, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>HSTS should be rolled out with a recovery plan</title><link>https://hk.xbcatm.com/notes/hsts-should-be-rolled-out-with-a-recovery-plan/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/hsts-should-be-rolled-out-with-a-recovery-plan/</guid><description>Increase max-age only after HTTPS, redirects, and every covered subdomain are ready.</description><content:encoded>&lt;p&gt;HTTP Strict Transport Security tells a supporting browser to use HTTPS for a host during a cached period. That persistence is the feature and the risk: removing the header does not immediately erase policy already stored by clients.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/hsts-rollout.detail.en.svg" alt="A rollout rail requires HTTPS and redirect checks, a short example policy and observation of site and renewal before choosing a duration. A domain tree confines includeSubDomains to the sending host’s descendants. Separate preload and recovery panels explain stored-policy limits." width="720" height="2530"&gt;&lt;figcaption&gt;Extend HSTS only after observation. Decide descendant scope and preload separately. Recovery needs valid HTTPS and cannot instantly erase every stored or preloaded policy.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="verify-the-https-path-first"&gt;Verify the HTTPS path first&lt;/h2&gt;
&lt;p&gt;Before sending HSTS, confirm that the canonical hostname serves a valid certificate and that HTTP redirects to the intended HTTPS URL without depending on user input:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null --max-redirs &lt;span class="m"&gt;0&lt;/span&gt; http://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Check representative deep routes and error pages. HSTS cannot repair a certificate error because the browser requires a valid HTTPS connection before accepting the policy.&lt;/p&gt;
&lt;h2 id="start-with-a-short-max-age"&gt;Start with a short max-age&lt;/h2&gt;
&lt;p&gt;A staged rollout limits the recovery window while configuration is new:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Strict-Transport-Security: max-age=300
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After observing the site and renewal path, increase the duration deliberately. Record the date, chosen value, and rollback conditions. A final policy might use a year, but the number is a commitment rather than a scanner score.&lt;/p&gt;
&lt;h2 id="treat-includesubdomains-as-a-separate-decision"&gt;Treat includeSubDomains as a separate decision&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;includeSubDomains&lt;/code&gt; extends policy to every subdomain. Inventory names first, including old services, delegated zones, and hostnames used only on internal networks. One HTTP-only descendant is enough to make the option disruptive for clients that received the parent policy.&lt;/p&gt;
&lt;h2 id="preload-is-not-ordinary-header-configuration"&gt;Preload is not ordinary header configuration&lt;/h2&gt;
&lt;p&gt;Browser preload programs can distribute policy outside the normal response cycle. Their requirements and removal delays are stricter than setting a header. Do not request preload merely because a scanner recommends it; confirm long-term ownership of the domain and all covered names.&lt;/p&gt;
&lt;h2 id="verify-the-public-response"&gt;Verify the public response&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null https://www.example.test/ &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;&amp;#39;tolower($1) == &amp;#34;strict-transport-security:&amp;#34; { print }&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Test the canonical public edge, not only an origin port. Proxies may add, remove, or duplicate the header.&lt;/p&gt;
&lt;h2 id="know-what-rollback-can-clear"&gt;Know what rollback can clear&lt;/h2&gt;
&lt;p&gt;A valid HTTPS response with &lt;code&gt;max-age=0&lt;/code&gt; clears that host’s learned policy only for clients that receive it. Removing the header or sending it over HTTP does not clear a cached policy. A child cannot opt out of a parent’s still-active &lt;code&gt;includeSubDomains&lt;/code&gt;, and clearing a parent does not delete policies independently learned for children.&lt;/p&gt;
&lt;p&gt;Scope follows the host that sends the header: policy from &lt;code&gt;www.example.test&lt;/code&gt; covers that host and, with &lt;code&gt;includeSubDomains&lt;/code&gt;, its descendants, not the sibling &lt;code&gt;api.example.test&lt;/code&gt; or the parent &lt;code&gt;example.test&lt;/code&gt;. Keep HTTPS working during recovery. Preload removal is a separate browser-update process, not an immediate response-header rollback.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6797.html"&gt;RFC 6797 — HTTP Strict Transport Security&lt;/a&gt;, policy processing and scope, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security"&gt;MDN: &lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/a&gt;, directives and deployment cautions, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hstspreload.org/"&gt;HSTS preload program&lt;/a&gt;, submission requirements and removal constraints, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>HEAD should describe the same representation as GET</title><link>https://hk.xbcatm.com/notes/head-should-describe-the-same-representation-as-get/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/head-should-describe-the-same-representation-as-get/</guid><description>Compare HEAD and GET metadata without confusing a missing body with a different resource.</description><content:encoded>&lt;p&gt;The &lt;code&gt;HEAD&lt;/code&gt; method asks for the response a server would send to &lt;code&gt;GET&lt;/code&gt;, without transferring the response content. It is useful for checks that need status, validators, type, or length, but only when the metadata remains aligned with the real &lt;code&gt;GET&lt;/code&gt; path.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/head-parity.detail.en.svg" alt="Matched request context, including Accept-Encoding, feeds GET and HEAD metadata cards. A content/checksum arrow exists only under GET. An absent HEAD Content-Length is allowed; a present value describes corresponding GET content. A small GET checks payload, and method-specific or redirect differences need investigation." width="720" height="2240"&gt;&lt;figcaption&gt;Match the URL, request headers, release, and content coding before comparing HEAD with GET. Permitted omissions are not automatic failures. Keep a small GET body or checksum check, because plausible metadata cannot prove that the payload is intact.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="compare-a-normalized-header-set"&gt;Compare a normalized header set&lt;/h2&gt;
&lt;p&gt;Dynamic headers such as &lt;code&gt;Date&lt;/code&gt; will differ between requests. Compare the fields that describe the selected representation:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://www.example.test/downloads/manual.pdf
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -I &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;gt; head.headers
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D get.headers -o /dev/null &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; file in head.headers get.headers&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;== %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; awk &lt;span class="s1"&gt;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; /^HTTP\// ||
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; tolower($1) ~ /^(content-type:|content-length:|content-encoding:|cache-control:|etag:|last-modified:|vary:)$/ { print }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; &amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run this in a disposable working directory; the two header files are overwritten. Match the URL, request headers, and release version. Compression negotiation belongs in the comparison: send the same &lt;code&gt;Accept-Encoding&lt;/code&gt; value to both requests.&lt;/p&gt;
&lt;p&gt;HEAD should describe the corresponding GET, but RFC 9110 permits omission of fields determined only while generating content. Missing &lt;code&gt;Content-Length&lt;/code&gt; alone is not a failure. If present on HEAD, it must be the length the corresponding GET content would have, not zero just because HEAD has no body. Compare encoded lengths consistently and investigate changed validators or types rather than requiring byte-identical header lists.&lt;/p&gt;
&lt;h2 id="do-not-use-head-as-the-only-release-check"&gt;Do not use HEAD as the only release check&lt;/h2&gt;
&lt;p&gt;A server can synthesize plausible headers while the file is missing, unreadable, or truncated. Keep one small &lt;code&gt;GET&lt;/code&gt; in the smoke test and verify its body or checksum. &lt;code&gt;HEAD&lt;/code&gt; reduces transfer cost; it does not prove the payload.&lt;/p&gt;
&lt;h2 id="check-redirects-explicitly"&gt;Check redirects explicitly&lt;/h2&gt;
&lt;p&gt;Without &lt;code&gt;--location&lt;/code&gt;, &lt;code&gt;curl -I&lt;/code&gt; reports the first response. That is useful when testing redirect policy. With &lt;code&gt;--location&lt;/code&gt;, it prints header blocks for the followed responses, including intermediate redirects and the final destination. Record which question the command answers:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -I --max-redirs &lt;span class="m"&gt;0&lt;/span&gt; https://old.example.test/manual
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -I --location https://old.example.test/manual
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="treat-method-specific-failures-as-configuration-defects"&gt;Treat method-specific failures as configuration defects&lt;/h2&gt;
&lt;p&gt;A static page that returns &lt;code&gt;200&lt;/code&gt; to &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;405&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; to &lt;code&gt;HEAD&lt;/code&gt; usually exposes a proxy or routing inconsistency. Fix the method handling instead of teaching monitors to ignore it, unless the endpoint contract deliberately disallows &lt;code&gt;HEAD&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-head"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, HEAD method semantics, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, &lt;code&gt;--head&lt;/code&gt;, &lt;code&gt;--include&lt;/code&gt;, and redirect options, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>