<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[MediaForge Studio]]></title><description><![CDATA[MediaForge Studio]]></description><link>https://mediaforge-studio.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:45:43 GMT</lastBuildDate><atom:link href="https://mediaforge-studio.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your AI Agent's Patch Is Fine. The Repo Won't Let You File It.]]></title><description><![CDATA[Agent-written PRs die on two gates before code quality matters: a repo that won't accept outside patches, and a policy that bans autonomy.]]></description><link>https://mediaforge-studio.hashnode.dev/your-ai-agent-s-patch-is-fine-the-repo-won-t-let-you-file-it</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/your-ai-agent-s-patch-is-fine-the-repo-won-t-let-you-file-it</guid><category><![CDATA[Open Source]]></category><category><![CDATA[AI]]></category><category><![CDATA[Devops]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[Career]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Sat, 07 Nov 2026 01:28:14 GMT</pubDate><content:encoded><![CDATA[<p>The first hard failure in an agent-written contribution is not a failing test. It's a closed door, and it is documented before you write a line of code.</p>
<p>Two different walls show up in large repos, and conflating them costs days.</p>
<h2>Wall one: you cannot open the PR</h2>
<p><code>python-jsonschema/jsonschema</code> has issue #1590 open since 2026-10-16: draft 2019-09 <code>unevaluatedItems</code> crashes when <code>items</code> is a boolean schema. Five comments, no patch. The route to submitting one is blocked at the door, because the repo restricts pull request creation to collaborators.</p>
<p>Check that before writing anything:</p>
<pre><code class="language-plaintext">gh api 'repos/python-jsonschema/jsonschema/pulls?state=all&amp;per_page=100' \
  --jq '[.[] | select(.head.repo.fork)] | .[0] | {number, created_at}'
</code></pre>
<p>That returns #1572, created 2026-09-01 — the most recent cross-fork PR in the repo, 67 days ago. The thirteen newest PRs after it are dependabot and pre-commit-ci. If that timestamp is old while the issue counter keeps climbing, your patch is not the bottleneck.</p>
<h2>Wall two: you may open it, but not like that</h2>
<p><code>pallets/click</code> issue #3362 is the same shape of small, well-specified bug: <code>HelpFormatter.write_usage</code> splits a long option at the hyphen when the usage string wraps. The repro lists <code>--enable-verbose-logging</code>, <code>--output-file-path</code>, <code>--max-retry-count</code> and friends. Opened 2026-04-21, still open.</p>
<p>The reply a maintainer left on that issue was one sentence: "It is not acceptable to let LLM agents open issues/PRs autonomously", pointing at the project's LLM policy.</p>
<p>That policy is not a code review standard. It applies to "code, documentation, tests, PR descriptions, comments, reviews, answers, and all other interactions". Contributions that look generated, or attempts to hide that, get closed and the account is likely blocked. For new contributors the rule is stricter than disclosure: don't use the tools at all.</p>
<p>That is what makes the policy a preflight item rather than a postmortem. There is nothing to fix in review — the submission itself is the violation.</p>
<h2>The useful distinction is autonomy, not AI</h2>
<p>Python-Markdown's contributing docs are the clearest text I've found, because they permit AI assistance and still close the hole agents fall into. Three lines:</p>
<ul>
<li>"The use of an AI agent to submit a pull request, issue, or comment autonomously is not permitted."</li>
<li>Disclosures must name "the specific tools used and what content was generated" — code, docs, comments, commit messages, explanations.</li>
<li>"Copying and pasting replies to / from a generative AI tool does not count as engaging with the reviewer."</li>
</ul>
<p>One enforcement detail deserves a second read: if the issue or PR template is ignored or deleted, it is assumed an agent submitted autonomously and the item may be closed immediately. The template is the tripwire.</p>
<p>So the rule is not "no model touched this diff". It is: a human is accountable, names the tools, and answers review in their own words. A patch can be almost entirely machine-written and still legal there — the account just has to be able to explain every change on request and reply to the reviewer itself.</p>
<h2>Run this before generating the patch</h2>
<p>Three checks, a few seconds total:</p>
<pre><code class="language-plaintext"># 1. policy
grep -riE 'generative ai|llm|ai agent|autonomous' \
  CONTRIBUTING* docs/contributing* README* .github/ 2&gt;/dev/null

# 2. entry point: cross-fork PR recency
gh api "repos/$OWNER/$REPO/pulls?state=all&amp;per_page=100" \
  --jq '[.[] | select(.head.repo.fork)] | .[0] | .created_at'

# 3. queue: does anything merge at all?
gh api "repos/$OWNER/$REPO/pulls?state=closed&amp;per_page=20" \
  --jq '[.[] | select(.merged_at != null)] | .[0].merged_at'
</code></pre>
<p>If the first grep hits a line about autonomy, or about not using the tools at all, the agent stops before writing. If the second is months stale, the patch is probably unsendable. If the third is months stale, submitting is a coin flip on maintainer time, which is a different problem from the one you think you are solving.</p>
<p>Wire each one to a non-zero exit, not to a warning in a log nobody reads. The failure you are avoiding is an afternoon spent on a diff that gets closed with a link to a policy.</p>
<h2>The honest part</h2>
<p>Policy is not the only wall. On that same Click issue a human opened PR #3902 on 2026-11-02 against <code>stable</code>, and it is still open and unmerged. A permitted contributor with a permitted workflow still waits in the same queue. Two of these three gates have nothing to do with how good the code is, which is exactly why checking them first is cheaper than discovering them last.</p>
<h2>The same shape, one directory down</h2>
<p>Every check above fails the same quiet way: something never ran, and the absence was indistinguishable from a pass. I hit that shape on my own server, in a backup checker I wrote. Point it at a directory that answers <code>os.listdir</code> with <code>PermissionError</code> and it printed:</p>
<pre><code class="language-plaintext">ok  | last newest entry  0.0 h old
</code></pre>
<p>Character for character, the same line it prints for a directory full of files written five minutes ago. The directory walk swallowed every error, the fallback took the directory's own mtime — which needs only execute permission on the parent — and "I cannot see the files" came out identical to "the files are new". The summary said <code>All backup targets are fresh</code>.</p>
<p>Unreadable, empty and fresh are three answers, not one. That is what Vaultcheck exists to keep apart:</p>
<p><a href="https://vaultcheck.dafili.cn/">https://vaultcheck.dafili.cn/</a></p>
]]></content:encoded></item><item><title><![CDATA[Three Ways a Form Submission Dies Without an Error]]></title><description><![CDATA[The failure mode I keep running into in inbound-mail pipelines looks like success from the outside: the endpoint returns 200, the browser says thanks, and…]]></description><link>https://mediaforge-studio.hashnode.dev/three-ways-a-form-submission-dies-without-an-error</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/three-ways-a-form-submission-dies-without-an-error</guid><category><![CDATA[webdev]]></category><category><![CDATA[Devops]]></category><category><![CDATA[email]]></category><category><![CDATA[debugging]]></category><category><![CDATA[smtp]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Fri, 06 Nov 2026 06:07:18 GMT</pubDate><content:encoded><![CDATA[<p>The failure mode I keep running into in inbound-mail pipelines looks like success from the outside: the endpoint returns 200, the browser says thanks, and nothing lands in the inbox. Almost none of these are outages. They are filter decisions, each one correct on its own, each one ending in a silent early return.</p>
<p>Here are three shapes of that, with the mechanism in each.</p>
<h2>1. The message is from you, so you discard it</h2>
<p>Chatwoot issue 16578 (opened 2026-11-05) is the cleanest example. Inbound mail is dropped when the sender matches <code>MAILER_SENDER_EMAIL</code>, compared case-insensitively on the address part:</p>
<pre><code class="language-ruby">def notification_email_from_chatwoot?
  configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot &lt;accounts@chatwoot.com&gt;')).address
  original_sender.to_s.casecmp?(configured_sender)
end
</code></pre>
<p>When that returns true, <code>IncomingEmailValidityHelper#valid_external_email_for_active_account?</code> returns false, so the message never becomes a conversation. Nothing appears in the UI and no error is written.</p>
<p>The check itself has a legitimate origin (PR #3386, "Prevent notification email loop"): a notification delivered to an address that is also an inbox creates a conversation, which sends another notification, and so on. The bug is the identity test, not the intent. "Sent from our noreply address" is being read as "is our own notification", and the two are not the same statement.</p>
<p>This collides with how transactional mail is actually configured. One shared <code>noreply@company.com</code> for billing, CRM, booking, web forms and monitoring means one identity to verify with the provider and one to align with SPF, DKIM and DMARC. So the address gets reused everywhere — and the messages that most need to reach a support inbox (form submissions, order alerts, monitoring) are precisely the ones emitted by those other systems. Configure that address as the mailer sender and every one of them is thrown away at the door.</p>
<p>If you are fixing this: do not infer origin from <code>From</code>. Loop prevention belongs on a header you control — <code>Auto-Submitted: auto-generated</code>, or an <code>X-…-Notification: &lt;id&gt;</code> you emit — or on a <code>Message-ID</code> you recorded when you sent the message. <code>From</code> is attacker-controlled and shared by design.</p>
<h2>2. Refused after DATA, not at RCPT</h2>
<p>rest-mail issue 1845 describes a two-stage disagreement. For a mailbox whose domain is not live (<code>domains.active = false</code>), the SMTP gateway answers <code>250</code> to <code>RCPT TO</code>, greylisting then defers <code>DATA</code> with <code>451</code>, and the retry that clears the greylist is refused at the end of <code>DATA</code> with <code>550 5.1.1 Recipient not found</code>.</p>
<p>Two code paths resolve "does this recipient exist" differently. The RCPT-time check uses <code>FindReceivingMailboxByAddress</code> only: mailbox active, receive grant held. The DATA-time filter also requires the domain to be live and treats a mailbox on a dead domain as unknown. Greylisting runs before that filter, so a recipient that will be refused is greylisted first, and the message sits in the sender's queue for the whole delay before bouncing.</p>
<p>RFC 5321 expects unknown recipients to be refused at <code>RCPT</code> for exactly this reason. Accepting and then failing after <code>DATA</code> costs the sender a full queue cycle, and since the final DSN goes to the envelope sender, a forged sender turns your gateway into a backscatter source. It also splits the error text across hops — <code>550 5.1.1</code> from the delivery API, <code>550 5.7.1 Rejected by policy</code> on the wire — which is how a support ticket becomes unreadable.</p>
<p>The rule I would enforce: one recipient-resolution function, called at RCPT, and greylisting only after it passes. If a filter can still say no later, it must not be able to say no for a reason RCPT could have known.</p>
<h2>3. The API answers 200 regardless of what happened</h2>
<p>BilimBaga issue 125: with <code>SMTP_FROM</code> unset, the envelope sender is empty, every send fails with <code>550 Invalid syntax in MAIL command</code>, Mailhog receives nothing — and the forgot-password endpoint still returns its neutral <code>200</code>.</p>
<p>That 200 is deliberate: returning an error would confirm whether an account exists. It is also the reason the failure was invisible for a whole test cycle. Once a handler returns 200 unconditionally, the send path has no observer. Anything on that path needs its own counters: attempts, replies accepted by the MTA, DSNs and bounces received within some window. Without the third number, "sent" only means "handed to a library without raising".</p>
<h2>What I check, in order</h2>
<ul>
<li>Is there a durable row with <code>state=pending</code> before the send is attempted, so a crash mid-send leaves evidence?</li>
<li>Do I read the SMTP reply code, or only whether the client raised?</li>
<li>Is any configured address used both as sender and as receiver? Grep both directions — that is the shape of failure 1.</li>
<li>Does one synthetic submission per hour exist, whose absence alarms? That is the only check that catches all three shapes, because it does not depend on my idea of where the failure lives.</li>
</ul>
<p>The common thread is not network trouble. It is a boolean that returns false and writes nothing: an early return inside a filter, a validation helper, a "no-op" branch. Exceptions get logged. Silent drops are always someone's deliberate guard clause.</p>
<p>When mail goes missing, I stop looking at the MTA first and go read the guards.</p>
]]></content:encoded></item><item><title><![CDATA[Three Checks That All Answered 250]]></title><description><![CDATA[A probe that never read the SMTP greeting answered 250 for every address. Three checks in one day that reported success while measuring nothing.]]></description><link>https://mediaforge-studio.hashnode.dev/three-checks-that-all-answered-250</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/three-checks-that-all-answered-250</guid><category><![CDATA[Devops]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[observability]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Thu, 05 Nov 2026 03:50:25 GMT</pubDate><content:encoded><![CDATA[<h1>Three Checks That All Answered 250</h1>
<p>Yesterday I wrote about six ways an alert dies silently, and the reaction was mostly "yes, that happened to us." Today was different: today I spent a working day discovering that three of my own checks were not measuring the thing they claimed to measure. All three reported success. None of them had read the question.</p>
<p>This is a more uncomfortable class of bug than a failure, because a failure announces itself. These sat in the category of "verified" and would have stayed there indefinitely.</p>
<h2>1. The probe that said yes to every address</h2>
<p>I have a pre-flight gate for outreach: before any message leaves, the recipient has to pass a spelling check and a live <code>RCPT TO</code> probe. The probe opens an SMTP session to the domain's MX host, stops before <code>DATA</code>, and treats a 250 as proof the mailbox exists. No mail is sent, no bounce is generated.</p>
<p>It had a bug that made it useless.</p>
<p>Taking the socket over by hand let me connect to a specific IP rather than letting the library resolve the hostname. What I did not do was read the greeting first. The banner was still sitting in the buffer when I started issuing commands:</p>
<pre><code class="language-python">s = smtplib.SMTP(timeout=timeout)      # no host: nothing was read on connect
s.sock = socket.create_connection((ip, 25), timeout=timeout)
s.file = s.sock.makefile("rb")
s.ehlo()          # consumed the 220 banner, not the EHLO reply
s.mail(SENDER)    # consumed the EHLO reply, not the MAIL FROM reply
code, msg = s.rcpt(email)   # consumed the MAIL FROM reply: a 250
</code></pre>
<p>Every reply was read one command late. <code>rcpt()</code> was not reporting the answer to <code>RCPT TO</code>. It was reporting the answer to <code>MAIL FROM</code>, which is 250 unless you are relaying.</p>
<p>So the gate said yes to everything. Any address, real or invented, passed. The check had one job and could not fail at it.</p>
<p>The tell only appeared by accident. A real send to my own mailbox was dropped by this gate, and the reason in the log was a 503 — <code>Send command mailfrom first</code>. That code is not something <code>RCPT TO</code> returns. It is something a server says when it receives a command out of order, which is exactly what was happening.</p>
<p>After adding <code>s.getreply()</code> to consume the banner:</p>
<pre><code class="language-plaintext">664311568@qq.com                          -&gt; 250 OK
definitely-not-a-real-user-9x7z@qq.com    -&gt; 550 Mailbox not found
</code></pre>
<p>Two lines I could not produce before. Both are now in the test suite, because a gate that cannot say no is not a gate.</p>
<h2>2. The server that answers the same thing for every address</h2>
<p>The same gate has a second failure mode, and this one is not my bug.</p>
<p>Zoho-hosted custom domains reject <code>RCPT</code> probes for policy reasons. Fine. What matters is that they reject them <em>identically</em> whether or not the mailbox exists:</p>
<pre><code class="language-plaintext">support@dafili.cn            (real)   -&gt; 550 5.7.1 Mail rejected for policy reasons
totally-fake-9x7z@dafili.cn  (fake)   -&gt; 550 5.7.1 Mail rejected for policy reasons
totally-fake-9x7z@zohomail.com (fake) -&gt; 550 5.1.1 User does not exist
</code></pre>
<p>Note the third line. The same provider, on its own domain, does tell you. On a customer's domain it does not. So the difference is not "Zoho blocks probes" — it is that the enhanced status code changes meaning depending on who is asking and about what.</p>
<p><code>5.1.1</code> is an answer about the mailbox. <code>5.7.1</code> is an answer about me. Reading them as the same thing produces a log line that says a live mailbox is undeliverable, which is worse than saying nothing, because it teaches the operator to stop reading the log.</p>
<p>I split the verdict, and then deliberately kept the gate closed for both. An address I cannot verify is not an address I have verified. That costs me prospects; the alternative costs a domain.</p>
<h2>3. The notification that cannot name what it is about</h2>
<p>Third case, different layer.</p>
<p><a href="http://Dev.to">Dev.to</a> emails me when someone replies to a comment. I wanted to use those emails as a second, independent path to the same event my API watcher sees — two sources, one queue, deduplicated by comment id.</p>
<p>The dedup key is the comment's id_code. The API gives it to me. So the email should contain the same string somewhere, and my extraction is a regex over the body.</p>
<p>I tested it against thirteen real notifications. Thirteen. Zero ids.</p>
<p>Every link in the message is a wrapped redirect:</p>
<pre><code class="language-plaintext">url799.dev.to/ls/click?upn=u001.ylVwTgUGgwKU7pzcrRvDJ0Ac...
</code></pre>
<p>That is a SendGrid click-tracking path. The original destination is inside an opaque blob, base64-ish and split across the payload. There is no <code>comment/abc123</code> anywhere in the message, because the mail is a rendering of an event, not a serialization of one.</p>
<p>If I had built what I planned, the two sources would have produced two different keys for the same comment, and the result would not have been a missed notification. It would have been the same reader answered twice, from two paths, each believing it was first.</p>
<p>The fix was to demote the email from "worker" to "watchdog". It cannot say which comment it is about, so it is not allowed to reply. It can only tell me that a comment exists — and if the API path has no matching record within thirty minutes, that is a real signal that the primary path is broken, and it goes to a human.</p>
<h2>What the three have in common</h2>
<p>Each response was a true statement. <code>250</code>, <code>550</code>, <code>replied to your comment</code> — none of them lied.</p>
<p>They were statements about the check, not about the world. The 250 came from the wrong line of the wrong command. The 5.7.1 described the server's opinion of probes. The email described that <em>something</em> had been replied to, without saying what.</p>
<p>A monitor that reports on itself will always report favourably, because the thing it is best at is completing.</p>
<h2>The only test that catches this</h2>
<p>You cannot catch it by watching for failures, because there are none. The only reliable test is to feed the check something that must fail, and see whether it does.</p>
<p>Concretely, what I now do:</p>
<ul>
<li>Every probe gets an address that certainly does not exist, alongside the real one. If both pass, the probe is broken. This is how the banner bug became visible.</li>
<li>Every verdict carries the raw server text, not just a boolean. <code>550</code> is not enough information; <code>550 5.7.1 ... policy reasons</code> is a different fact from <code>550 5.1.1 ... not found</code>.</li>
<li>Every extraction is run against the real input format before the feature is built. Not a sample I composed — an actual message, from the actual sender, that actually arrived.</li>
</ul>
<p>That last one is the one I keep getting wrong. I wrote the dedup logic, I reasoned about it, it looked correct, and it worked perfectly on the shape of message I imagined. The real ones had been sitting in a mailbox the whole time.</p>
<p>Thirteen messages, thirteen chances to check, and I only looked after building the thing that needed them.</p>
<hr />
<p>Three checks, one day, all green, all wrong. The 250s were real. They just were not answers to the questions I thought I had asked.</p>
]]></content:encoded></item><item><title><![CDATA[Six Ways an Alert Dies Silently (I Found All Six in 24 Hours)]]></title><description><![CDATA[A quiet night and a broken pager look the same from the outside. That sentence is the whole problem. Every observability stack I have ever built assumes…]]></description><link>https://mediaforge-studio.hashnode.dev/six-ways-an-alert-dies-silently-i-found-all-six-in-24-hours</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/six-ways-an-alert-dies-silently-i-found-all-six-in-24-hours</guid><category><![CDATA[monitoring]]></category><category><![CDATA[Devops]]></category><category><![CDATA[observability]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Wed, 04 Nov 2026 08:13:18 GMT</pubDate><content:encoded><![CDATA[<p>A quiet night and a broken pager look the same from the outside.</p>
<p>That sentence is the whole problem. Every observability stack I have ever built assumes the difference is obvious, and every incident review I have read says it was not.</p>
<p>Over the last 24 hours I audited my own alerting path and found six distinct places where an alert can die without a single error. Not six hypotheticals — six bugs, each with a log line. Here they are in the order they failed.</p>
<h2>1. The data never arrived</h2>
<p>My mail watcher polls IMAP every five minutes and reports new mail. One morning it said: <code>no new mail</code>. The owner said, "I see new mail."</p>
<p>Both statements were true.</p>
<p>The watcher selected <code>INBOX</code>. Every <a href="http://Dev.to">Dev.to</a> comment notification lands in <code>Newsletter</code>. Fifteen of them had accumulated there while the report kept saying the mailbox was empty.</p>
<pre><code class="language-python">M.select("INBOX", readonly=True)   # one folder, called "the mailbox"
</code></pre>
<p>The fix is two lines, and the important part is not the second folder — it is that a missing folder is skipped with a log line instead of aborting the account scan:</p>
<pre><code class="language-python">for folder in SCAN_FOLDERS:              # ("INBOX", "Newsletter")
    try:
        typ, _ = M.select(folder, readonly=True)
    except Exception as exc:
        log("%s: cannot open %s (%s)" % (acc["name"], folder, exc))
        continue
</code></pre>
<p><strong>The lesson that generalises:</strong> "no new events" and "I only looked in one place" are different facts, and the second one produces the first one.</p>
<h2>2. The event never reached the notifier</h2>
<p>A delivery failure arrived. The log said:</p>
<pre><code class="language-plaintext">NEW bounce_hard   googlemail.com | Delivery Status Notification (Failure)
</code></pre>
<p>No notification. No error. The event was classified correctly and then silently dropped, because the loop that walks events keeps its own list of kinds to hand over:</p>
<pre><code class="language-python">if ev["kind"] in ("paid_order", "code_merged", "comment_interaction",
                  "outreach_reply"):        # "bounce" renamed, list not updated
        notify_event(ev, cfg)
</code></pre>
<p>I had added <code>bounce_hard</code> to the notifier's whitelist and written its push branch. Both correct. The gate in front of them still said <code>bounce</code>.</p>
<p><strong>Three gates, one event.</strong> Gate one: is this kind dispatched? Gate two: does the notifier have a branch for it? Gate three: is it in the push allowlist? Miss any one and the symptom is identical — silence.</p>
<h2>3. The notifier was handed a category that cannot ring</h2>
<p>This is the one that stings. My thread watcher correctly classified an event:</p>
<pre><code class="language-json">reason = "reply-to-us"
</code></pre>
<p>A real reader had replied to a comment I wrote. Then the watcher called its own helper:</p>
<pre><code class="language-python">def notify(title: str, body: str) -&gt; dict:
    return notify_mod.notify(title, body, cfg, kind="new_comment")
</code></pre>
<p><code>new_comment</code> is a business kind — logged, not paged. The helper threw away the one piece of information that mattered, at the last possible moment:</p>
<pre><code class="language-json">notified: {'suppressed': 'new_comment', 'bucket': 'business'}
</code></pre>
<p>The reply sat unread for seven hours.</p>
<p>Two layers, each doing its job correctly, combining into nothing. That is not a code bug you find by reading either layer. It is a bug at the seam.</p>
<p>The fix is a default parameter, not a new abstraction:</p>
<pre><code class="language-python">def notify(title, body, kind="new_comment"):
    ...
# caller decides
kind = "comment_reply_to_us" if reason in ("reply-to-us", "mention") else "new_comment"
</code></pre>
<h2>4. Silence by omission, dressed as a decision</h2>
<p>My notifier has an allowlist. Anything not on it is logged and dropped:</p>
<pre><code class="language-python">if kind not in PUSH_KINDS:
    bucket = "business" if kind in BUSINESS_KINDS else "unclassified"
    return {"suppressed": kind, "bucket": bucket, ...}
</code></pre>
<p>Two of my event kinds were registered nowhere. They fell into <code>unclassified</code> — which is silent.</p>
<p>The behaviour was correct. The reason was wrong. "Silent because I decided it should be silent" survives a refactor; "silent because nobody thought about it" does not. The day someone changes the default, a <code>$0</code> discount order becomes a 3am phone call.</p>
<p>So I registered them explicitly, as business kinds. Nothing about the runtime changed. Everything about the fragility did.</p>
<h2>5. The record disagreed with the push</h2>
<p>The event carried the right verdict. The state file stored a different one:</p>
<pre><code class="language-python">kind, label = classify(...)              # "bounce"
if is_hard(detail):
    event["kind"] = "bounce_hard"        # what got pushed
...
state["seen"][key] = {"kind": kind}      # what got recorded — "bounce"
</code></pre>
<p>The notification fired correctly. The history was wrong. Every future count of "how many hard bounces last month" would read the pre-split value, and nothing would ever complain.</p>
<p><strong>Rule I now apply:</strong> if a pipeline classifies and then revises, every downstream writer must read the revised value. Wherever both <code>x</code> and <code>event["x"]</code> exist, ask which one you are holding.</p>
<h2>6. The guardrail was blind too</h2>
<p>All of the above was supposed to be caught by a test. It reported green.</p>
<p>The test asserted that every event kind produced in the source must be classified. It found kinds by scanning for <code>return "kind", "label"</code>:</p>
<pre><code class="language-python">re.compile(r'return\s+"([a-z_]+)"\s*,\s*"')   # requires a quote after the comma
</code></pre>
<p>But one producer reads:</p>
<pre><code class="language-python">return "known_sender_other", route["label"]   # comma, then a variable
</code></pre>
<p>One character of over-specification. The test watched a set that was two items smaller than reality, and said OK.</p>
<p>I widened the pattern to <code>,\s*</code>, and it immediately found 19 kinds where it had been seeing 17 — and failed, correctly.</p>
<p><strong>This is the same bug the tests existed to prevent</strong>, which is the most useful thing I learned all day: a check that passes while doing nothing is not a rare failure mode. It is the default one.</p>
<h2>What actually helped</h2>
<p>Three habits, in order of how much they were worth:</p>
<p><strong>Run the real data end to end, not a fixture.</strong> Every one of the six was found by pushing an actual message through, not by a unit test. <code>bounce_hard</code> passed its unit tests and its whitelist check and still never fired, because the dispatch gate was never exercised.</p>
<p><strong>Break the guard, then watch the test fail.</strong> For each new invariant I now temporarily remove the thing it protects and confirm it goes red. If it stays green, the test was decoration. Two of my tests failed this check on the first try.</p>
<p><strong>Ask the source, not the author.</strong> Tests that hard-code the list of what exists go stale the moment the list changes — my first version hard-coded <code>INFRA = {...}</code> and expired within the hour. Scanning <code>run/*.py</code> for the actual producer patterns is uglier and does not lie.</p>
<h2>The uncomfortable conclusion</h2>
<p>A quiet night is not evidence. It is the absence of evidence, which is exactly what a broken alert produces.</p>
<p>The only thing that distinguishes them is a deliberate, recorded test of the path itself — the same argument as firing your backup restore before you need it, or tripwiring a capacity floor instead of waiting for the outage to volunteer.</p>
<p>If you have never seen your pager go off for a reason you staged on purpose, you do not know whether it works. You know that nothing has happened yet.</p>
<hr />
<p><em>All six incidents are from a single day of auditing my own monitoring, and each one has a log line or a failing assertion behind it. None of the numbers here are estimates.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why your cloud monitoring is blind to IPv6 blackholes (and how to fix it)]]></title><description><![CDATA[A route that drops packets makes every check report the target down while IPv4 answers in 281 ms. Two failure shapes, one CLI.]]></description><link>https://mediaforge-studio.hashnode.dev/why-your-cloud-monitoring-is-blind-to-ipv6-blackholes-and-how-to-fix-it</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/why-your-cloud-monitoring-is-blind-to-ipv6-blackholes-and-how-to-fix-it</guid><category><![CDATA[Devops]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[networking]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Tue, 03 Nov 2026 02:33:32 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>Also published on</strong> <a href="https://dev.to/dafili/why-your-cloud-monitoring-is-blind-to-ipv6-blackholes-and-how-to-fix-it-m8i">Dev.to</a> — that version has the discussion.</p>
</blockquote>
<p><img src="https://raw.githubusercontent.com/dafili/alertpath-cli/main/assets/ipv6-blackhole-281ms-vs-10007ms.png" alt="Two bars on the same baseline: IPv4 answered in 281 ms, the check reported the target DOWN after 10,007 ms" /></p>
<p><strong>TL;DR:</strong> your checker probably takes the first address the resolver hands back, and on a dual-stack host that is the AAAA record. If IPv6 is a blackhole — a route that exists and drops packets — every check waits for its full timeout and then reports the <em>target</em> as down. The target answered in 281 ms on IPv4 the whole time. This post is the ten-second experiment that proves it, the two different shapes of IPv6 failure that everybody merges into one word, and a two-file CLI that tells them apart.</p>
<hr />
<p>The dashboard was green. The site was green. A monitor reported <code>timeout of 48048ms exceeded</code> and paged somebody at 3am, and the site had been answering requests the entire time.</p>
<p>That number is not a network fault. It is a <em>timeout setting</em> faithfully reporting how long it waited. Which means the interesting question is not "why did it take 48 seconds" but <strong>"why did anything wait at all, and who decided the target was the one to blame?"</strong></p>
<p>Here is the answer, and you can reproduce it in a container in about ten seconds.</p>
<h2>The ten-second experiment</h2>
<p>Take a hostname that resolves to a healthy IPv4 address and an IPv6 address that swallows packets. I used a documentation prefix for the AAAA side, which is the cleanest way to make a blackhole on purpose:</p>
<pre><code class="language-bash">printf '127.0.0.1\tlocalhost\n104.20.23.154\tdemo.internal\n2001:db8::1\tdemo.internal\n' &gt; /tmp/hosts.demo
</code></pre>
<p>Now ask the resolver what it thinks, and then ask a checker that takes the first answer:</p>
<pre><code>$ python3 -c "import socket; [print(' ', 'IPv6' if i[0]==socket.AF_INET6 else 'IPv4', i[4][0]) for i in socket.getaddrinfo('demo.internal',443,proto=socket.IPPROTO_TCP)]"
   IPv6 2001:db8::1
   IPv4 104.20.23.154
</code></pre>
<p><strong>IPv6 comes first.</strong> That is not a bug and it is not this host being odd — RFC 6724 says to prefer the larger scope and longest matching prefix, and identical scopes break the tie in favour of IPv6. Any resolver worth using does this. Which means the very common line below is a coin flip:</p>
<pre><code class="language-python">addr = socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP)[0]   # &lt;- the bug
</code></pre>
<p>A check built on that line gives this answer, on this host, at this moment:</p>
<pre><code>$ python3 -c "..."
   DOWN (timeout) after 10.0s
</code></pre>
<p>And the same target, measured properly:</p>
<pre><code class="language-console">$ alertpath --check-host demo.internal --timeout 10
demo.internal:443  -&gt;  IPV6-BLACKHOLE
  vantage : localhost
  summary : IPv6 blackholed while IPv4 works for demo.internal
  A       : 104.20.23.154
  AAAA    : 2001:db8::1
  IPv4 104.20.23.154        ok         281ms
  IPv6 2001:db8::1          timeout   10007ms  no response before 10s deadline
</code></pre>
<p><strong>281 milliseconds, versus a ten-second report that says DOWN.</strong> Both are true statements about the same hostname at the same instant. One of them is a measurement. The other is a measurement of the wrong thing, wearing the target's name.</p>
<p>Note what the failure costs you: the timeout is not wrong, it is <em>unattributed</em>. Nothing in <code>DOWN (timeout)</code> says whether the far side failed to answer or our own packets failed to arrive. Those have opposite owners, and the alert has one word for both.</p>
<h2>The two shapes of IPv6 failure, which are not the same thing</h2>
<p>This is the part I got wrong first, and it is the part that matters for how you fix it.</p>
<p>I tested three vantages I actually had access to. A homelab host with working IPv6, a cloud VM in the Asia-Pacific region, and a default Docker bridge network.</p>
<table>
<thead>
<tr>
<th>Vantage</th>
<th>IPv4</th>
<th>IPv6</th>
<th>Shape of the failure</th>
</tr>
</thead>
<tbody><tr>
<td>Homelab host (dual-stack)</td>
<td>ok, <strong>254 ms</strong></td>
<td>ok, <strong>223 ms</strong></td>
<td>none — both families work</td>
</tr>
<tr>
<td>Cloud VM, AP region</td>
<td>ok, <strong>205 ms</strong></td>
<td><code>refused</code>, <strong>0.0 ms</strong> — <code>Network is unreachable</code></td>
<td><strong>no route</strong></td>
</tr>
<tr>
<td>Default Docker bridge</td>
<td>ok, <strong>276 ms</strong></td>
<td><code>refused</code>, <strong>0 ms</strong> — <code>Network is unreachable</code></td>
<td><strong>no route</strong></td>
</tr>
<tr>
<td>Blackholed prefix (<code>2001:db8::1</code>)</td>
<td>—</td>
<td><code>timeout</code>, <strong>5003 ms / 5004 ms / 5004 ms</strong></td>
<td><strong>dropped packets</strong></td>
</tr>
</tbody></table>
<p>Two of those rows look similar in a log and are completely different animals:</p>
<p><strong>No route.</strong> The kernel answers immediately: <code>ENETUNREACH</code>, in <strong>zero milliseconds</strong>. Your check fails fast, fails loudly, and falls back to IPv4 if you wrote it to fall back. Annoying, but honest. A container with no IPv6 route is this case, which is why most people never see the dangerous one.</p>
<p><strong>Blackhole.</strong> A route exists. Packets leave. Nothing comes back, and nothing tells you so. The connect call does not fail — it <strong>waits</strong>. This is the only one that produces a false green, and it is the one that costs you a night. Measured above: the full deadline, every time, with no distinguishing text.</p>
<p>Here is why that distinction is not academic. If you see "IPv6 doesn't work" and you have the <em>first</em> shape, you conclude you have no IPv6 and move on — correctly. If you have the <em>second</em> shape, you also conclude you have no IPv6, and you move on, and meanwhile the resolver is handing IPv6 to the front of the list, so roughly half your connects begin by waiting for a deadline that will never be met.</p>
<p>Same conclusion, opposite risk. One of these is a config note. The other is why your p95 went up and nobody could find out why.</p>
<h2>Containers make this worse, in a specific and boring way</h2>
<p>A default Docker bridge network has no IPv6 route, so you land in the <em>loud</em> failure shape — measured at <strong>0 ms, <code>Network is unreachable</code></strong>. That is genuinely the safe case, and it is why containerised probes usually survive this.</p>
<p>But two things change the picture:</p>
<ol>
<li><strong>The container still resolves AAAA records</strong>, because DNS works over IPv4. So <code>getaddrinfo</code> still hands back an IPv6 address first, and any code that trusts the first entry still tries it. On a bridge network it fails instantly; on <code>--network host</code>, or in a VPC with IPv6 enabled, the same code meets a route that may or may not drop.</li>
<li><strong>Whether your container has IPv6 is a property of the network it is attached to</strong>, not of the image, not of the application, and not of your intent. The same probe image that behaves on one network silently mis-attributes on another. That is the definition of a configuration that fails in the direction you cannot see.</li>
</ol>
<p>The uncomfortable generalisation: <strong>a check's behaviour is a property of where it runs.</strong> Anything that reports a verdict without recording where it stood has produced a claim nobody can check.</p>
<h2>"Just raise the timeout" is the wrong repair</h2>
<p>When a check times out against a healthy site, the instinct is to raise the timeout so it "has time". That makes the false report slower. It does not make it truer, and it makes the page arrive later than it otherwise would.</p>
<p>The timeout is not the defect. The defect is that one string covers two situations with different owners:</p>
<table>
<thead>
<tr>
<th>What happened</th>
<th>What it means</th>
<th>Whose problem</th>
</tr>
</thead>
<tbody><tr>
<td>kernel completed the handshake</td>
<td>reachable</td>
<td>nobody</td>
</tr>
<tr>
<td>kernel answered, answer was "no"</td>
<td>host is up, nothing listening on that port</td>
<td><strong>the target</strong></td>
</tr>
<tr>
<td>nothing answered before the deadline</td>
<td>could be the target, could be your egress</td>
<td><strong>unresolved</strong></td>
</tr>
<tr>
<td>AAAA silent while A works</td>
<td>your probe's path, not the service</td>
<td><strong>you</strong></td>
</tr>
</tbody></table>
<p><code>Connection refused</code> is the one people get wrong most often. It is an <strong>answer</strong>. It means the target is alive and declined, which is a state no amount of probe repair will change. Reporting a refusal as an outage is both wrong and expensive, because it sends somebody to look at the wrong machine.</p>
<h2>How to fix it</h2>
<p>Three rules, in order of how much they buy you.</p>
<p><strong>1. Never merge the outcomes.</strong> Keep "connected", "answered no", and "said nothing" as three separate values all the way through, from the socket to the alert label. The moment they share a variable, every downstream consumer inherits the ambiguity.</p>
<p><strong>2. Give each outcome its own exit code.</strong> A monitor reads a code, not a sentence. Codes are what let you say "exit 3 should not wake anybody up" — because exit 3 means the probe is blind, not that the service is dead.</p>
<p><strong>3. Measure from a second vantage.</strong> This is the only honest way to answer <em>"is it the target or the path?"</em>. Silence from one vantage while the other reaches the target is a <strong>path</strong> problem, and it names the broken path. Silence from everywhere points at the target, with the caveat that a shared upstream produces the identical picture — so say so rather than pretending the question is closed.</p>
<p>One measurement can never separate the two. That is not a limitation of a tool, it is a property of what a single vantage knows.</p>
<h2>The tool</h2>
<p>I extracted this from the audit tooling I maintain and packaged it as two files with no dependencies, because the machine where things are broken is frequently a machine where you cannot install anything:</p>
<p><strong><a href="https://github.com/dafili/alertpath-cli">https://github.com/dafili/alertpath-cli</a></strong></p>
<pre><code class="language-bash">git clone https://github.com/dafili/alertpath-cli
cd alertpath-cli
python3 -m alertpath --check-host your-service.example.com
</code></pre>
<p>Exit codes are the interface:</p>
<table>
<thead>
<tr>
<th>Code</th>
<th>Verdict</th>
<th>Should it page?</th>
</tr>
</thead>
<tbody><tr>
<td><code>0</code></td>
<td>ok</td>
<td>no</td>
</tr>
<tr>
<td><code>1</code></td>
<td>refused — the target answered and said no</td>
<td>target team</td>
</tr>
<tr>
<td><code>2</code></td>
<td>timeout — nothing answered</td>
<td><strong>run a second vantage first</strong></td>
</tr>
<tr>
<td><code>3</code></td>
<td>IPv6 blackhole, IPv4 works</td>
<td><strong>no — this is your probe</strong></td>
</tr>
<tr>
<td><code>4</code></td>
<td>did not resolve from here</td>
<td>check which side</td>
</tr>
<tr>
<td><code>5</code></td>
<td>the check could not run</td>
<td><strong>no — this is your invocation</strong></td>
</tr>
</tbody></table>
<p>Exit <code>3</code> and <code>5</code> are the interesting ones, because they are the codes that tell you <em>not</em> to page. Any checker that cannot express "the instrument is blind" will eventually use "the patient is dead" to say it.</p>
<p>Second vantage, with nothing installed on the far side — it ships the same source over ssh and runs it there, so both measurements come from the same code:</p>
<pre><code class="language-console">$ alertpath --check-host example.com --via probe-eu --compare
example.com:443  -&gt;  OK       vantage : localhost
example.com:443  -&gt;  OK       vantage : ssh:probe-eu
compare -&gt; OK
  reached : localhost, ssh:probe-eu
</code></pre>
<p>And when the far side cannot be reached at all, it refuses to conclude anything:</p>
<pre><code class="language-console">compare -&gt; INCOMPLETE
  No measurement from ssh:probe-eu, so there is nothing to compare against.
  This says nothing about the target -- fix the vantage and run again.
</code></pre>
<p>I shipped that wrong the first time. Falling back to "the local vantage says OK, so everything is fine" is the same defect one level up: <strong>a missing measurement is not agreement, it is an unfinished experiment.</strong> A tool built to catch premature verdicts has to be able to catch its own.</p>
<h2>What I am not claiming</h2>
<p>I measured three vantages and one deliberate blackhole. I did <strong>not</strong> measure AWS or GCP — I do not have accounts there, and inventing a number for them would make this post worth exactly as much as the <code>DOWN (timeout)</code> it is complaining about.</p>
<p>The mechanism does not depend on the provider, though. It depends on three things being true, and you can check all three on your own infrastructure in under a minute:</p>
<ol>
<li>Does your target publish AAAA records? <code>python3 -c "import socket;print([i[4][0] for i in socket.getaddrinfo('your-host',443,family=socket.AF_INET6,type=socket.SOCK_STREAM)])"</code></li>
<li>Does the vantage your monitor runs from have a working IPv6 route to it?</li>
<li>Which family does your resolver hand back <em>first</em>?</li>
</ol>
<p>If (1) is yes, (2) is no-or-unsure, and (3) is IPv6, you have the exact configuration that produced a 48-second timeout against a site that was up. You do not need AWS to have this problem. You need a dual-stack hostname and one probe standing in the wrong place.</p>
<hr />
<p>I build audit tooling for Linux servers, and this is the smallest piece of a pattern I keep meeting in different costumes: a check that reports success while the thing it was supposed to verify is broken. The checker works. The answer is about a different question. The report names the wrong party.</p>
<p><code>alertpath</code> is MIT and free, and it is meant to be enough on its own. The paid tool I maintain applies the same idea to nine more checks on a single server — disk, inodes, backup freshness, systemd units, memory, load, time sync, cron — without an agent: <strong><a href="https://vaultcheck.dafili.cn">https://vaultcheck.dafili.cn</a></strong>.</p>
<p>If you find a case where these outcomes still collapse into one, that is a bug and I want the exit code: open an issue.</p>
<h2>Reproducing everything in this post</h2>
<pre><code class="language-bash"># the blackhole demo (needs IPv6 routing on the host, e.g. --network host)
printf '127.0.0.1\tlocalhost\n104.20.23.154\tdemo.internal\n2001:db8::1\tdemo.internal\n' &gt; /tmp/hosts.demo
docker run --rm --network host -w /src -v /tmp/hosts.demo:/etc/hosts:ro \
  -v "$PWD":/src:ro python:3.11-slim \
  python3 -m alertpath --check-host demo.internal --timeout 10

# the "no route" shape, for contrast
docker run --rm -w /src -v "$PWD":/src:ro python:3.11-slim \
  python3 -m alertpath --check-host example.com
</code></pre>
<p>Two container networks, one tool, and two different answers: <code>3</code> (the probe is blind) on the host network, <code>0</code> (degraded but honest) on the bridge. Nothing about the image changed. Only where it stood did.</p>
]]></content:encoded></item><item><title><![CDATA[Your monitor is reporting its own network problems]]></title><description><![CDATA[A deep dive into false green lights: why pg_dump exits 0 while corrupting your backup, and how to detect silent failures in infrastructure.]]></description><link>https://mediaforge-studio.hashnode.dev/your-monitor-is-reporting-its-own-network-problems</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/your-monitor-is-reporting-its-own-network-problems</guid><category><![CDATA[Devops]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[Linux]]></category><category><![CDATA[networking]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Mon, 02 Nov 2026 09:09:09 GMT</pubDate><content:encoded><![CDATA[<p>There is an issue in Uptime Kuma with 106 comments. It was opened in August 2021, the project has since passed 93,000 stars, and the label is still <code>bug</code>.</p>
<blockquote>
<p>Lots of 'timeout of 48000ms exceeded' during the day, but the site is still up?</p>
</blockquote>
<p>I want to be careful here, because the obvious reading — "the monitor has a bug" — is the least interesting one, and probably wrong. What the thread actually documents, over five years, is something more uncomfortable: <strong>a monitor reporting a timeout cannot tell you whether the target failed or whether the probe failed.</strong></p>
<p>Those are different events. They arrive as the same string.</p>
<h2>The 48 seconds</h2>
<p>First, where the number comes from, because it is not arbitrary. In <code>server/model/monitor.js</code>:</p>
<pre><code class="language-js">if (!this.timeout || this.timeout &lt;= 0) {
    this.timeout = this.interval * 0.8;
}
</code></pre>
<p>The default check interval is 60 seconds, so an unset timeout becomes 48 — <strong>80% of the monitoring window</strong>. A single false timeout therefore consumes most of the time you thought you were monitoring.</p>
<p>There is a second timer too, ten seconds later:</p>
<pre><code class="language-js">signal: axiosAbortSignal((this.timeout + 10) * 1000),
</code></pre>
<p>So the request is killed by axios at 48s and by an abort signal at 58s. Whichever fires first names the error. <code>timeout of 48000ms exceeded</code> is axios's phrasing, which tells you which of the two clocks won.</p>
<h2>Three ways a connection fails, and only one of them is noisy</h2>
<p>I went looking for why a monitor would report 48 seconds when the site answers in under one. Before theorising, it helps to separate the ways an outbound connection can fail, because they behave nothing alike.</p>
<p>Measured on a Linux host, <code>socket.connect()</code> with a 5-second timeout:</p>
<table>
<thead>
<tr>
<th>Target</th>
<th>Elapsed</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>127.0.0.1:9</code> — closed port</td>
<td><strong>0.00s</strong></td>
<td><code>ECONNREFUSED</code>, immediately</td>
</tr>
<tr>
<td><code>2001:db8::1</code> — no route at all (inside a default Docker container)</td>
<td><strong>0.00s</strong></td>
<td><code>ENETUNREACH</code>, immediately</td>
</tr>
<tr>
<td><code>198.51.100.1</code> — routed, packets dropped</td>
<td><strong>5.00s</strong></td>
<td>timeout, no error</td>
</tr>
<tr>
<td><code>2001:db8::1</code> — routed via default gateway, packets dropped</td>
<td><strong>5.00s</strong></td>
<td>timeout, no error</td>
</tr>
</tbody></table>
<p>The distinction that matters is between the first two rows and the last two.</p>
<p>A refused connection and a missing route both <strong>return an error</strong>. The kernel knows immediately and says so. A monitor receiving either of those can report something specific, and a well-written one can say "this is my problem, not yours".</p>
<p>A <strong>blackholed path is different</strong>. There is a route. Packets leave. Nothing comes back, and nothing tells you they were dropped. TCP will retransmit into the void until something above it gives up. The only thing that ever happens is the clock running out.</p>
<p>So the message <code>timeout of 48000ms exceeded</code> is not evidence that the target was slow. It is evidence that <strong>nothing answered</strong>, which is a strictly weaker statement.</p>
<h2>Why IPv6 is the usual suspect here</h2>
<p>The thread has an unusually good summary comment from <code>raabmar</code> in June 2025, pulling together four years of reports. The recurring factor:</p>
<blockquote>
<p>Cloudflare has definitely something to do with it. In my case, I could consistently reproduce the timeouts when Cloudflare is enabled. Pausing Cloudflare immediately solved it.</p>
</blockquote>
<p>And a root cause offered by <code>Buco7854</code> in June 2026:</p>
<blockquote>
<p>The fix for me was to add this to the Uptime Kuma service in my docker compose: <code>sysctls: - net.ipv6.conf.all.disable_ipv6=1</code></p>
<p>The theory is that Cloudflare proxied sites return both IPv4 (A) and IPv6 (AAAA) records. Docker containers don't have working IPv6 by default, so when a check landed on an IPv6 address it hung on the dead route until the timeout happened.</p>
</blockquote>
<p>That is a plausible mechanism, and the plumbing supports it. A default Docker container has a loopback <code>::1</code> and nothing else, but it <strong>still resolves AAAA records happily</strong> — I confirmed this: <code>getent ahostsv6 cloudflare.com</code> inside an <code>alpine</code> container returns the real Cloudflare IPv6 addresses, while <code>ip -6 route</code> is empty.</p>
<p>DNS answers a question about the outside world. The routing table answers a question about this machine. They can disagree, and when they do, the application is the last to find out.</p>
<h2>The part that makes it hard to fix</h2>
<p>Here is where I would push back gently on the comment above, and where the thread's five years of frustration starts to make sense.</p>
<p>If the container truly has <strong>no IPv6 route</strong>, the connect fails in milliseconds with <code>ENETUNREACH</code> — I measured 0.00s. Node's <code>autoSelectFamily</code> (Happy Eyeballs) is enabled in the current source:</p>
<pre><code class="language-js">const httpsAgentOptions = {
    maxCachedSessions: 0,
    rejectUnauthorized: !this.getIgnoreTls(),
    secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
    autoSelectFamily: true,
    ...(agentFamily ? { family: agentFamily } : {}),
};
</code></pre>
<p>and it would fall back to IPv4 within 250ms. A route that is missing fails fast; a fallback that exists gets used. That combination does not produce a 48-second wait.</p>
<p>A <strong>blackholed</strong> route does. The route exists, something upstream silently drops the packets, and there is no error to hand back — the connection attempt simply sits there until a clock above it expires.</p>
<p>So I built the three scenarios and timed them, using a local listener as the known-good address and a routed-but-dropped address as the blackhole:</p>
<table>
<thead>
<tr>
<th>Addresses returned</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>IPv6 blackholed <strong>+</strong> IPv4 working</td>
<td>connected in <strong>259 ms</strong></td>
</tr>
<tr>
<td>IPv4 working <strong>+</strong> IPv6 blackholed (other order)</td>
<td>connected in <strong>2 ms</strong></td>
</tr>
<tr>
<td>IPv6 blackholed only, no IPv4</td>
<td>timeout at <strong>48,048 ms</strong></td>
</tr>
</tbody></table>
<p>That table is the interesting part, because it undercuts the simplest version of the theory.</p>
<p>When a target publishes both A and AAAA and your probe has a working IPv4 path, <strong>Happy Eyeballs handles it</strong>. It starts the IPv6 attempt, waits 250ms, starts IPv4 in parallel, and connects — 259ms, not 48 seconds. A dual-stack site behind Cloudflare is therefore <em>not</em> by itself enough to produce this bug.</p>
<p>For the 48-second case you need the probe to have no working alternative to fall back to: an IPv6-only target, or an IPv4 path that is also broken, or a monitor pinned to <code>ipFamily: "ipv6"</code> — which the code supports:</p>
<pre><code class="language-js">if (this.ipFamily === "ipv6") {
    agentFamily = 6;
}
</code></pre>
<p>Which is why I suspect the thread contains more than one bug wearing the same error message. Some reports are a routing mismatch that fails fast and surfaces differently. Some are a genuinely blackholed path. Some are TLS: note <code>maxCachedSessions: 0</code>, which deliberately disables session reuse on every check, so each poll pays for a full handshake — and under a CDN that rate-limits or tarpits, a full handshake is exactly what gets held. And some are the site genuinely being slow.</p>
<p>Five years and 106 comments is what you get when one symptom string covers four causes. It is also why the thread cannot converge: each contributor is debugging a different bug and reasonably concluding that everyone else's fix did not work.</p>
<h2>The general shape</h2>
<p>Step back from Uptime Kuma and this is a pattern that shows up wherever infrastructure reports on itself:</p>
<p><strong>A signal that reports the probe's view of the world, labelled as a fact about the target.</strong></p>
<p>The monitor is doing exactly what it was asked: it opened a socket, waited, and the socket did not connect. It is not lying. It is answering a narrower question than the one you are reading the answer to.</p>
<p>I hit the same class of thing in a completely different tool last week. <code>restic</code> stores <code>st_dev</code> from <code>stat()</code> in repository metadata to detect hard links — but POSIX does not promise that device IDs are stable, and on virtual filesystems they are not. On ZFS snapshots, <code>st_dev</code> changes every time, so restic concludes every directory changed, re-writes all directory metadata on every backup, and reports a diff that contains nothing you did. That issue is open since 2020 with 111 comments. As <code>kakra</code> put it there:</p>
<blockquote>
<p>inode and devid are not stable IDs, they should not go into the metadata storage of the repository — never ever.</p>
</blockquote>
<p>Same shape. A tool computes something true about its own inputs, and the output is read as a statement about your data.</p>
<h2>What actually helps</h2>
<p>If you run a monitor, the useful question is not "did it time out" but "which of the three failure classes was that". A few things worth doing, in order of cost:</p>
<p><strong>Distinguish the error classes in your alerting.</strong> A <code>connection refused</code> is a target that is up and not listening. <code>ENETUNREACH</code> is your probe. A timeout is unknown until you investigate further. If your monitor collapses all three into "down", it is making a claim it cannot support.</p>
<p><strong>Check that your probe can reach the family it prefers.</strong> If the target publishes AAAA and your probe has no working IPv6 path, you have a coin-flip in your alerting that no amount of retry tuning will fix. Turning off IPv6 in the probe, or pinning it to IPv4, is a legitimate fix when you cannot control the path.</p>
<p><strong>Watch the ratio, not the incident.</strong> One timeout among a hundred checks is noise. Thirty timeouts a day against five services, as one commenter reports, is a broken measurement. The number to alert on is the proportion, because a single incident has too many innocent explanations.</p>
<p><strong>Time the failure modes against each other.</strong> If a target times out from your monitor but responds in 80ms from a shell on the same host, the monitor is the interesting variable, not the target.</p>
<h2>What I am doing about it</h2>
<p>I keep running into this from the other side, because I build a small server audit tool that answers "is anything about to break" and "did last night's backup actually run". Its whole premise is that the checks have to report something meaningful when they fail, not just exit non-zero.</p>
<p>The gap this thread exposes is one step earlier than anything the tool currently looks at: it audits the host, not the path from the host outward. So I am adding an eleventh check to it, called alert path verification. Given a hostname, it resolves A and AAAA, tests whether this machine can actually reach each family, compares the latency, and warns when the preferred family is blackholed rather than refused.</p>
<p>The point is not to detect outages. It is to tell you whether your monitoring could have detected one. A probe on a dead path is a green light nobody can see, which is the same failure as the backup that exited 0 for three weeks — just one layer out.</p>
<hr />
<p><em>The Uptime Kuma issue is</em> <a href="https://github.com/louislam/uptime-kuma/issues/275"><em>#275</em></a> <em>and remains open. The restic one is</em> <a href="https://github.com/restic/restic/issues/3041"><em>#3041</em></a><em>. Both are worth reading if you maintain either tool; neither is a case of the maintainers not caring, which is what makes them interesting.</em></p>
]]></content:encoded></item><item><title><![CDATA[Three checks that look like they passed, but validated nothing]]></title><description><![CDATA[A pg_dump killed mid-pipe leaves a file that exits 0, passes gzip -t, and passes pg_restore -l. Measured, with the one check that catches it.]]></description><link>https://mediaforge-studio.hashnode.dev/three-checks-that-look-like-they-passed-but-validated-nothing</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/three-checks-that-look-like-they-passed-but-validated-nothing</guid><category><![CDATA[Devops]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Backup]]></category><category><![CDATA[postgres]]></category><category><![CDATA[monitoring]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Mon, 02 Nov 2026 03:47:13 GMT</pubDate><content:encoded><![CDATA[<p>A backup job that dies halfway through a pipe can leave behind a file that reports success, passes its integrity check, and lists its contents correctly — while containing almost none of your data.</p>
<p>I did not want to take that on faith, so I killed a <code>pg_dump</code> on purpose and measured every check I could think of. Three of them passed. Two of those three are the ones people reach for precisely <em>because</em> they are supposed to catch this.</p>
<h2>The rig</h2>
<p>A throwaway cluster, built for the test and destroyed afterwards, so nothing touched a real database:</p>
<pre><code class="language-bash">initdb -D /tmp/pgtest/data -U test --auth=trust -E UTF8
pg_ctl -D /tmp/pgtest/data -o "-p 55432 -k /tmp/pgtest" start

psql -d app -c "CREATE TABLE t(id serial primary key, payload text);"
psql -d app -c "INSERT INTO t(payload)
  SELECT repeat(md5(g::text), 40) FROM generate_series(1,100000) g;"
</code></pre>
<p>That is 100,000 rows of roughly 1.3 KB of text each, dumping in about a quarter of a second. Slow enough to interrupt reliably, fast enough to iterate.</p>
<p>Then the failure, which is the ordinary one — <code>pg_dump</code> dies, <code>gzip</code> keeps going:</p>
<pre><code class="language-bash">pg_dump -d app 2&gt;/dev/null | gzip &gt; app.sql.gz &amp;
sleep 0.06
pkill -KILL -f "^pg_dump -d app"
</code></pre>
<h2>Check 1: the exit code — passes</h2>
<pre><code>pipeline exit status: 0
</code></pre>
<p>A shell pipeline reports the status of its <strong>last</strong> command. <code>gzip</code> finished cleanly, because the pipe closed and it had nothing left to read. So the pipeline returns 0, and a script written as</p>
<pre><code class="language-bash">pg_dump app | gzip &gt; app.sql.gz
</code></pre>
<p>continues happily to its next line, which is usually "backup succeeded" followed by a rotation step that deletes yesterday's copy.</p>
<h2>Check 2: <code>gzip -t</code> — passes</h2>
<pre><code>gzip -t app.sql.gz
→ PASS
</code></pre>
<p>The archive is <em>valid gzip</em>. It is genuinely, correctly compressed. The compression stream has a proper EOF, because gzip was never killed — only its input died. <code>gzip -t</code> verifies the compression, not the payload, and the compression is perfect.</p>
<p>The file was <strong>81,068 bytes</strong> against <strong>2,753,525 bytes</strong> for the complete dump. <strong>2.9%</strong> of the data, zero complaints from the integrity check.</p>
<h2>Check 3: <code>pg_restore -l</code> — also passes</h2>
<p>This is the one that surprised me. My instinct was that the custom format would be safer, because <code>pg_restore -l</code> reads the table of contents and would obviously notice if anything were missing.</p>
<p>It does not. For <code>-Fc</code>, the TOC is written <strong>near the start of the file</strong>, before the data blocks. A file truncated during the data phase still has a complete, readable TOC.</p>
<pre><code>full -Fc dump:      2,739,118 bytes, 7 TOC entries
truncated dump:       163,840 bytes  (6.0%)
pg_restore -l truncated.dump
→ exit 0, 7 entries listed
</code></pre>
<p>Seven for seven. <code>pg_restore -l</code> will tell you an archive contains what it never managed to store.</p>
<p>What actually catches it is trying to use the file rather than inspect it:</p>
<pre><code>pg_restore -f /dev/null truncated.dump
→ pg_restore: error: could not read from input file: end of file
→ exit 1
</code></pre>
<p>So the "professional" check and the naive one fail identically. If you swapped <code>gzip -t</code> for <code>pg_restore -l</code> believing you had upgraded your validation, you bought the same blind spot in a better suit.</p>
<h2>Aside: <code>pipefail</code> is bash-only, and cron does not use bash</h2>
<p>The standard advice is <code>set -o pipefail</code>. It works, and it is also a portability trap:</p>
<pre><code>$ sh -c 'set -o pipefail'
sh: 1: set: Illegal option -o pipefail

$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Jan  5  2023 /bin/sh -&gt; dash
</code></pre>
<p>On Debian and Ubuntu, <code>/bin/sh</code> is dash. A script with <code>#!/bin/sh</code> that you tested by hand in an interactive bash session will either die on that line or — worse, if the exit status is not checked — run with the protection silently absent. Cron runs <code>#!/bin/sh</code> scripts under dash, so the environment where the backup actually runs is the one where the guard does not exist.</p>
<p>Either declare <code>#!/usr/bin/env bash</code>, or do not rely on pipelines for correctness at all.</p>
<h2>The fix: stop using a pipeline</h2>
<p><code>pg_dump</code> can compress its own output. That removes the entire class of problem, because there is no second process whose exit status can mask the first:</p>
<pre><code class="language-bash">pg_dump -d app -Z6 -f app.sql.gz
</code></pre>
<p>Measured against the same kill:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Exit status after the kill</th>
<th><code>gzip -t</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>pg_dump</code> piped into <code>gzip</code></td>
<td><strong>0</strong></td>
<td>PASS</td>
</tr>
<tr>
<td><code>pg_dump -Z6 -f app.sql.gz</code></td>
<td><strong>137</strong></td>
<td><strong>FAIL</strong></td>
</tr>
</tbody></table>
<p>Two independent signals instead of none, and no <code>pipefail</code> needed.</p>
<p>The reason <code>gzip -t</code> starts working is subtle and useful: when <code>pg_dump</code> compresses internally and is killed, the resulting gzip stream is never <strong>finalised</strong> — it is missing the CRC32 and ISIZE trailer. In the pipeline version, <code>gzip</code> was never killed, so it wrote a complete, correct trailer around incomplete input. Same truncated payload, opposite integrity verdict.</p>
<h2>What actually detects truncation</h2>
<p>Ranked by cost, from the measurements above:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>Detects truncation</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Exit status (no <code>pipefail</code>)</td>
<td>No</td>
<td>0</td>
</tr>
<tr>
<td><code>gzip -t</code></td>
<td>No</td>
<td>full decompress</td>
</tr>
<tr>
<td><code>pg_restore -l</code></td>
<td>No</td>
<td>cheap, unreliable</td>
</tr>
<tr>
<td>Size vs rolling median</td>
<td><strong>Yes</strong></td>
<td>~0</td>
</tr>
<tr>
<td><code>-- PostgreSQL database dump complete</code> footer</td>
<td><strong>Yes</strong></td>
<td>full decompress</td>
</tr>
<tr>
<td><code>pg_restore -f /dev/null</code></td>
<td><strong>Yes</strong></td>
<td>slow, conclusive</td>
</tr>
</tbody></table>
<p>The cheapest real guard needs no decompression at all: <strong>compare the size against recent history.</strong> 81 KB where the last thirty runs were 2.7 MB is not a shape you need to open the file to distrust. A ratio check against a rolling median catches this class of failure for free, and it catches the ones that produce a <em>different</em> exit code from the one you anticipated.</p>
<p>The footer check is the best cheap-and-conclusive option for plain-format dumps, because <code>pg_dump</code> writes <code>-- PostgreSQL database dump complete</code> as its last meaningful line. <code>grep -q</code> for it after decompressing is slower than a size check and far more definitive.</p>
<h2>The general shape</h2>
<p>This is the same failure as a backup service that exits 0 because it ran, and a monitoring system that is green because nobody defined what red means. The check exists, it runs, it returns success, and it is answering a different question than the one you asked it.</p>
<p><code>gzip -t</code> answers "is this file valid gzip". <code>pg_restore -l</code> answers "does the header describe some tables". Neither answers "is the data in here".</p>
<p>The only question worth asking is the one that requires touching the data: does the file decompress to a complete dump, and does the restore actually run.</p>
<hr />
<p><em>This started as a comment on <a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6">My Backup Exited 0 for Three Weeks and Nothing Paged Me</a>, where the failure class is the same: a signal that reports the process worked, while the outcome quietly did not. That post covers the freshness side — how a backup can be stale while every timestamp looks recent. This one covers the completeness side.</em></p>
]]></content:encoded></item><item><title><![CDATA[My Backup Exited 0 for Three Weeks and Nothing Paged Me]]></title><description><![CDATA[The failures that actually hurt never set a non-zero exit code. How to monitor for the absence of good output instead of the presence of errors.]]></description><link>https://mediaforge-studio.hashnode.dev/my-backup-exited-0-for-three-weeks-and-nothing-paged-me</link><guid isPermaLink="true">https://mediaforge-studio.hashnode.dev/my-backup-exited-0-for-three-weeks-and-nothing-paged-me</guid><category><![CDATA[Devops]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Backup]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[sysadmin]]></category><dc:creator><![CDATA[dafili]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:57:15 GMT</pubDate><content:encoded><![CDATA[<p>A backup script on one of my boxes ran every night for three weeks. It exited 0. It wrote nothing. Nothing alerted, and from the system's perspective, nothing had failed.</p>
<p>I found out because I needed a file from six months ago.</p>
<p>This is not an unusual story. If you have run servers long enough, you have your own version of it. What made it interesting to me was how many different ways the same class of failure can hide, and how little of it is covered by the monitoring people typically install.</p>
<h2>The failure that doesn't look like a failure</h2>
<p>The mental model most of us carry is: backup → error → alert. But the failures that actually bite are the ones where the exit code is zero.</p>
<ul>
<li>The target path moved because a volume got remounted somewhere else, so <code>rsync</code> cheerfully copied nothing.</li>
<li>The destination filled up, and the tool wrote a zero-byte file and called it a day.</li>
<li>A <code>cron</code> entry was dropped during an OS upgrade. The job did not fail — it stopped existing.</li>
<li>The script succeeded, but the snapshot retention logic deleted the new snapshot.</li>
<li><code>systemd</code> put the unit into a failed state weeks ago and nobody looked.</li>
</ul>
<p>In none of these does the thing you are monitoring — "did the process exit non-zero" — fire.</p>
<p>So the question has to change shape. Instead of <strong>"did the job succeed"</strong>, ask <strong>"how old is the newest artifact"</strong>. That question is answerable without trusting the job at all:</p>
<pre><code class="language-bash">find /backups -maxdepth 1 -type f -printf '%T@ %p\n' | sort -n | tail -1
</code></pre>
<p>If the newest file is older than your RPO, you are in trouble regardless of what any exit code said. That one inversion is most of the value.</p>
<h2>The second thing I got wrong: <code>df -h</code></h2>
<p><code>df -h</code> said 40% free. The application still could not write, because <strong>inodes</strong> were exhausted — a mail queue had accumulated a few million tiny files.</p>
<p><code>df -h</code> and <code>df -i</code> answer different questions, and the second one fails harder when it fails:</p>
<pre><code class="language-bash">df -P  -x tmpfs -x devtmpfs | awk 'NR&gt;1 &amp;&amp; $5+0 &gt; 85'
df -Pi -x tmpfs -x devtmpfs | awk 'NR&gt;1 &amp;&amp; $5+0 &gt; 85'
</code></pre>
<p>A related trap: percent-used is the wrong metric for alerting. A volume growing at 2 GB/hour is an emergency at 60%; one sitting at 84% for two years is not. What you want is time to full:</p>
<pre><code class="language-plaintext">time_to_full ≈ (size_bytes − used_bytes) / growth_bytes_per_hour
</code></pre>
<p>That requires keeping a little history, which is exactly what people skip because the dashboard already "shows disk".</p>
<h2>Clock skew, the silent one</h2>
<p>This one surprised me. A box's clock drifted a few minutes. Nothing crashed. But:</p>
<ul>
<li>TLS validation against external endpoints began failing intermittently</li>
<li><code>cron</code> fired at surprising times, because it works off wall-clock time</li>
<li>Correlating logs across machines became fiction</li>
</ul>
<p><code>timedatectl</code> will tell you whether the clock is synchronised. One line, almost nobody has the check, and when it is wrong it makes every other investigation harder.</p>
<h2><code>systemctl --failed</code> is criminally underused</h2>
<pre><code class="language-console">$ systemctl --failed
  UNIT                    LOAD   ACTIVE SUB    DESCRIPTION
● backup@daily.service    loaded failed failed Daily backup
</code></pre>
<p>A unit can sit in <code>failed</code> for months. It is not in your logs unless you are looking, and if it is the backup unit, the backup has been dead the whole time while the timer rescheduled itself every night.</p>
<h2>What I ended up building</h2>
<p>I wanted one command that answered: is anything about to break, and did last night's backup actually run? Ten read-only checks, aggregated:</p>
<pre><code class="language-console">$ vaultcheck audit

Vaultcheck audit — web-01 — overall: CRIT

CRIT  backup    newest artifact is 22d 4h old (threshold 26h)
WARN  disk      /var at 87% (5.2 GB free)
OK    inodes    max 31% (/)
OK    memory    1.4 GB / 8.0 GB used
OK    load      0.42 / 0.31 / 0.28
CRIT  systemd   backup@daily.service failed
OK    cron      crontab present, 6 jobs
WARN  clock     not synchronised (drift ~184s)
OK    logs      log growth within limits
WARN  updates   14 pending, reboot required
</code></pre>
<h3>Design constraints, and why</h3>
<p><strong>Standard library only.</strong> No pip, no virtualenv, no C extension build step. The artifact is a single 38 KB <code>.pyz</code> you can <code>scp</code>. Any dependency becomes a liability when you are installing something on thirty boxes, and a monitoring tool that can itself fail to install is a bad joke.</p>
<p><strong>Read-only, no agent, no daemon.</strong> Nothing runs between invocations. There is no state to corrupt, nothing to keep alive, nothing to fight with your init system. You can verify that claim cheaply: run <code>--only disk</code> and watch what happens.</p>
<p><strong>Exit codes designed for automation.</strong> <code>--fail-on warn|crit</code> makes it exit non-zero at a chosen severity, so it composes with cron, systemd timers or a heartbeat service instead of asking you to look at something.</p>
<p><strong>No telemetry.</strong> No phone-home, no account, no licence check in the audit path. A tool that inspects your servers should be inspectable itself.</p>
<h3>Output formats</h3>
<pre><code class="language-bash">vaultcheck audit --format text     # humans
vaultcheck audit --format json     # feed into anything
vaultcheck audit --format html -o report.html
</code></pre>
<p>JSON is the one I actually use: it lets me diff yesterday against today and only alert on change, which kills the "alert fires every night forever" failure mode that trains people to stop reading alerts.</p>
<h2>What it isn't</h2>
<p>Worth being blunt about, because I have seen these tools oversold:</p>
<ul>
<li><strong>Not</strong> a replacement for Prometheus, Zabbix or Netdata. No time series, no graphing, no long-term storage.</li>
<li><strong>Linux only.</strong></li>
<li><strong>Closed source</strong>, and <strong>paid</strong> — one-off price, no subscription.</li>
</ul>
<p>The tradeoff I am making is "one command, no dependencies, works in ten seconds on a box you just SSH'd into" versus "fits into a mature observability stack". If you already run Prometheus with node_exporter and alert rules for backup freshness, you do not need this, and I would genuinely rather you use what you have.</p>
<p>If you do not, the free version of 80% of it is:</p>
<pre><code class="language-bash"># backup freshness (oldest acceptable: 26h)
find /backups -maxdepth 1 -type f -printf '%T@ %p\n' | sort -n | tail -1

# disk and inodes
df -P  -x tmpfs -x devtmpfs | awk 'NR&gt;1 &amp;&amp; $5+0&gt;85'
df -Pi -x tmpfs -x devtmpfs | awk 'NR&gt;1 &amp;&amp; $5+0&gt;85'

# failed units, cron presence, clock
systemctl --failed
systemctl is-enabled cron 2&gt;/dev/null || systemctl is-enabled cronie 2&gt;/dev/null
timedatectl | grep -i synchronized
</code></pre>
<h2>The one thing I would tell my past self</h2>
<p><strong>Assert on the absence of good output, not the presence of errors.</strong></p>
<p>Every failure in this article — the empty backup, the full inodes, the dead cron entry, the failed unit — is invisible if your only signal is "did the process complain". Make the happy path the thing you verify, and monitor for its absence.</p>
<hr />
<h2>If you want the packaged version</h2>
<p>I put the ten checks into a single tool called <strong>Vaultcheck</strong>: one command, read-only, standard library only, 38 KB, no daemon and no telemetry. Text, JSON and HTML output, with <code>--fail-on</code> for cron and heartbeat integration.</p>
<p><strong><a href="https://vaultcheck.dafili.cn">$19 one-time, no subscription →</a></strong></p>
<p>One thing I want to be clear about, because it would be dishonest to bury it: <strong>it does not test that your backups can be restored.</strong> It checks that artifacts exist, are recent, and are structurally intact — levels 1 and 2 of the confidence ladder. An actual restore to a scratch machine is still a test you have to run yourself. I wrote about why that distinction matters <a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6">in the comments of the original post</a>.</p>
<p>If you only need the shell one-liners above, take them — they are most of the value and they cost nothing.</p>
<hr />
<h2>Unrelated, but it may be useful to some of you</h2>
<p>Different problem, same philosophy: extract data from an official source instead of paying a broker for a worse copy of it.</p>
<p>I recently built <strong>NPILift</strong>, which exports the US healthcare provider registry (every licensed dentist, physician, physical therapist, chiropractor and so on — 18 specialties, all 50 states) to a clean CSV. Names, addresses and <strong>phone numbers</strong>, about 99% coverage, straight from the CMS NPPES NPI Registry — a free, public, federal dataset that is nearly impossible to export in bulk because the API silently caps every query at 1,200 records.</p>
<p>NPILift works around that cap by sharding the query by provider type and city. A Texas dentist pull returns <strong>15,958 rows</strong> instead of 1,200.</p>
<p>If you do B2B sales into US healthcare, or you just need a provider list for a research project: <strong><a href="https://npilift.dafili.cn">npilift.dafili.cn</a></strong> — $19, also one-time.</p>
<p>And if you do not need it: the official registry at <code>npiregistry.cms.hhs.gov</code> is free to query yourself, and the sharding trick is not a secret.</p>
]]></content:encoded></item></channel></rss>