On 2026-08-26 I stopped my own marketing site. The homepage was getting rebuilt, so nginx on 134.209.46.197 went to a placeholder and TLS came down with it:
$ curl -sI https://vodou.ai
curl: (7) Failed to connect to vodou.ai port 443: Connection refused
$ curl -s http://vodou.ai | head -c 60
<html><head><title>Website has been stopped</title>
Planned. Fine. The next day a cleanup pass over my blog repo came back with a tidy little report: seven dead external links, all pointing at https://vodou.ai, and a proposed patch retargeting them at https://blog.vodou.ai. Every one of those seven was correct. Not "correct if you squint." Correct in the sense that they described a real relationship between a person, an organization, and a website, and the only thing wrong with them was that a probe fired during a window I had chosen.
Two of the seven were the expensive ones. In blog-site/src/layouts/Base.astro, line 45 is worksFor: { '@type': 'Organization', name: 'Vodou', url: 'https://vodou.ai' } and line 60 is the publisher.url for the whole site's JSON-LD. Rewriting those does not break a page. It quietly asserts that the blog is its own publisher, which merges two entities that Google had been keeping apart, and it does so in a diff that reviews as a lint fix. The other five (line 211's footer anchor, about.astro:14, [...slug].astro:73, llms.txt.ts:45, llms-full.txt.ts:18) are cheaper, but they are still five places where the truth got replaced by the reachable thing.
200 on :80, refused on :443, and neither answer was about the link
I spent the first stretch of this convinced the checker was right and my infrastructure was wrong. That is the reflex: the tool says dead, the tool has a socket and I have a memory, believe the socket. So I went looking for the outage. Was the Let's Encrypt renewal cron dead? Was there a DigitalOcean firewall rule I had added and forgotten? Had the droplet's nginx config been clobbered by the rebuild? I burned a good hour on a problem I had personally caused on purpose the day before, because the report was phrased as a defect and I read it as one.
The thing that broke it loose was running the two probes side by side instead of trusting one:
$ curl -s -o /dev/null -w '%{http_code}\n' http://vodou.ai
200
$ curl -s -o /dev/null -w '%{http_code}\n' https://vodou.ai
000
A 200 and a connection refusal, from the same host, ninety milliseconds apart. The placeholder page is a real, healthy, fully served HTTP response whose body says the site is off. A checker that follows http:// calls the host alive. A checker that honors https:// calls it dead. Both are accurately reporting what they saw, and neither observation has anything to do with the question I actually cared about, which is: should this href be this string?
That question is not answerable by a probe, because it is not a question about now. The link is a claim about 2026-09-02, when the new homepage goes up. Nothing you can measure on 2026-08-26 evaluates a claim about the following week.
The repo declared the URL and never declared the host's intended state
Here is the part where I get to be embarrassed. Twenty lines above the publisher block I broke, in the same file, I had already written this rule down, for a different field:
// Entity reconciliation: these are the profiles that let a search engine tie
// "Chad Priest" here to the same author elsewhere ...
// Every URL below was fetched and returned 200 before it was listed.
// A sameAs pointing at a 404 is a worse signal than no sameAs at all,
// so this list must never grow by guess.
That comment is good advice and it is the exact instinct that nearly destroyed publisher.url. "Verify liveness before you assert a URL" is a rule about adding a reference. Applied to an existing reference it inverts into "delete anything not currently answering," and I had written the first half without noticing the second half was implied and wrong. The repo had five files declaring where the org lives and zero files declaring that the org's host was intentionally off until September. The intent existed only in my head, and later in a one-line note in my own memory store:
- vodou.ai is intentionally down as of 2026-08-26 (nginx 'website has been
stopped' placeholder on 134.209.46.197, :443 refused). Chad is building the
new Vodou homepage and expects it live ~2026-09-02. Do NOT rewrite
blog.vodou.ai's schema.org publisher.url / Person.worksFor.url or the 7
footer links away from https://vodou.ai — the host is coming back, the links
are correct in advance. app.vodou.ai is separate and up.
A note in a memory store is better than nothing, and it is not a fix, because the thing that proposes the edit does not read it.
The standard advice here is the maintenance window. Dynatrace's docs describe it precisely: declare planned non-standard operation, suppress notifications, avoid automated actions, keep availability math honest. That last two are exactly my failure mode, and the model is right. What it does not cover is that a maintenance window is scoped to telemetry inside one observability platform, and none of the four things that looked at my links live there. A link checker in a different repo's CI, Google's Rich Results test, a stranger's crawler, and an agent doing an autonomous cleanup pass all have no API into my maintenance window and never will. Intent has to travel with the artifact, in the repo, or it does not reach the consumer that acts on it.
The closest prior art I found is levelsofself's "Your monitoring cannot tell broken from off", which lands the core move: intent has to be data, not code, and the checker asks whether actual matches declared. It gets it right for processes it owns. Two things it does not carry, both of which bit me: the declaration needs an expiry (a permanent "intended: off" is just a blind spot with better manners), and the subject of the declaration is not only hosts I run, it is references I ship. My blog does not own vodou.ai. It links to it.
And the status-code layer cannot rescue you here. The jupyterlab check-links PR adds transient_status_codes and fail_on_transient so 429/500/502/503/504 and timeouts stop reading as hard breakage, which is a real improvement. A stopped site behind a placeholder returns 200, cleanly, forever. There is no code in that list for "up, correct, and not what I mean yet."
The class: probes that can edit references but cannot read intent
This is the general shape: an automated validator that can propose edits, checking a target whose current state is intentional, with no channel by which the operator's intent reaches the validator. The validator sees a fact, infers a defect, and repairs a correct thing into an incorrect one. It shows up far outside blogs. CI link linters like lychee and htmltest do it to READMEs during a partner's migration. Config drift reconcilers do it when someone scales a deployment to zero on purpose and the next terraform apply scales it back. DNS-based failover does it when a region is drained deliberately and health checks route traffic back into it. Agentic coding assistants do it hardest, because they will not only flag the reference, they will write the commit.
A check that can rewrite a reference must first read a declared intended state for that reference's target, and a target with no declaration is a finding it reports, never a defect it repairs.
That is true or false of your codebase right now, and you can go look. If the only inputs to your auto-fix are a URL and a status code, it is false.
The fix in my repo was boring: a link-policy.json at the root, and one comparison in front of the repair path.
{
"hosts": {
"vodou.ai": { "intended": "down", "since": "2026-08-26",
"expected_live": "2026-09-02",
"reason": "homepage rebuild; references correct in advance" },
"app.vodou.ai": { "intended": "up" }
}
}
const d = policy.hosts[host];
if (!d) return { verdict: "UNDECLARED", fix: false };
if (d.intended === "down") return today > d.expected_live
? { verdict: "OVERDUE", fix: false, page: true }
: { verdict: "EXPECTED_DOWN", fix: false };
return observed.ok ? { verdict: "OK" } : { verdict: "BROKEN", fix: false, report: true };
Note that fix is false on every branch. The checker lost its edit rights entirely, and I have not missed them. Note also expected_live: on 2026-09-03 the exemption stops being a mute and starts being an alarm, which is the difference between this and a --exclude vodou.ai flag that silences the host until the heat death of the repo.
Run this in five minutes: a fake stopped host and one grep of your own repo
Stand up the exact ambiguity locally. Two links, same "host", one on a port serving a placeholder and one on a port with nothing on it:
mkdir -p /tmp/stopped && cd /tmp/stopped
printf '<html><head><title>Website has been stopped</title></head></html>' > index.html
python3 -m http.server 8080 >/dev/null 2>&1 &
cat > page.md <<'EOF'
[coming back next week, placeholder up](http://127.0.0.1:8080/)
[coming back next week, tls not up yet](https://127.0.0.1:8443/)
EOF
lychee --no-progress page.md # or: htmltest, markdown-link-check, your CI's checker
You will get one OK and one error. That is the whole point: your checker just gave two opposite verdicts about one intent, and if anything downstream auto-fixes on the error, it will now "repair" a link whose only sin is arriving early. Kill the server when you are done (kill %1).
Now the part that tells you something about your own code. List the external hosts your repo actually asserts, then ask whether anything declares what they are supposed to be:
grep -rIoh 'https\?://[A-Za-z0-9.-]\+' . --exclude-dir=.git --exclude-dir=node_modules \
| sed -E 's#https?://##' | sort | uniq -c | sort -rn | head -20
jq -r '.hosts | keys[]' link-policy.json 2>/dev/null || echo "NO POLICY FILE"
Passing output: every host in the first list that appears more than once has a key in the second. Failing output is NO POLICY FILE, or a short key list next to a long host list, and the gap between the two columns is the set of references any validator in your pipeline is free to rewrite on the strength of a socket error. In my repo that gap was 7 vodou.ai against nothing at all.
One more grep, the one that decides how bad the gap is:
grep -rn 'autofix\|--fix\|write_file\|apply_patch' .github/workflows/ 2>/dev/null
If a link check and an autofixer appear in the same workflow, your probe has edit rights over claims it cannot evaluate.
The rule I would give a stranger: a system may report on what it observes, and may only change what someone declared. If the only thing your tooling knows about a URL is whether it answered, then "it did not answer" is a sentence about the network, not a sentence about your code, and it should never be allowed to write.
Source: Your link checker read planned downtime as a broken URL by Chad Priest, from Building Vodou in Public.
Top comments (0)