The Honest 404: Debugging Content Negotiation with Synthetic Probes

🤖 Read Raw Markdown

Setting the Stage: Context for the Curious Book Reader

In the Age of AI, the gap between what a system claims to do and what it actually delivers is the most dangerous blind spot a developer can possess. This treatise explores the mechanics of verification through the lens of a failed experiment, proving that the most valuable signal in a broken system isn’t the green dashboard—it’s the honest error.


Technical Journal Entry Begins

🔗 Verified Pipulate Commits:

Earlier today I got this note from an illustrious coworker. Let’s call him Jeff, one of the greatest DIY-guys I’ve ever known and I am humbled and grateful and have already fixed what he found. Thanks Jeff! The learning here is so important Here’s how it went down that led to this continuous independent cross-verifications.

The Ghost in the Configuration

— BEGIN MESSAGE —

COWORKER  

I was thinking your fishbowl can track to see if anything this tool recommends and if it helps with AI Bots on the site.

https://isitagentready.com/mikelev.in

Like, I know you server Markdown, but this tool doesn’t pick it up.

I learned that my test site would 200ok MD files when you hit them directly, but would 403 when ai bots hit them. Had to refactor somethings to get them through.

— END MESSAGE —

The whole “tracer dye” content-negotiation part of the experiment had not actually been taking place! So what was that point-two-two (.22%) percent of clients who were negotiating for it? Ohhh! They must have been negotiating for it and I was recording that they did but I was never actually serving it! Oh my goodness. Let’s fix that.

Bridging the Gap Between Intent and Execution

MikeLev.in: Write a Python script that tests whether a provided URL supports content negotiation for text/markdown.

Claude Sonnet 5: Run it with: python3 check_markdown_negotiation.py https://example.com

The Sequencing Trap of Infrastructure as Code

How it works:

  1. Sends a baseline request with no special Accept header.
  2. Sends a request with Accept: text/markdown, text/html;q=0.5, */*;q=0.1 (markdown preferred).
  3. Sends a control request with Accept: text/html, application/json, */*;q=0.1 (markdown excluded).
  4. Compares the Content-Type and body across these, and checks for a Vary: Accept header.

It concludes negotiation is supported only if the server actually returns text/markdown when asked and the response changes based on the Accept header (ruling out servers that always return markdown or ignore Accept entirely). Requires pip install requests if not already installed.

#!/usr/bin/env python3
"""
check_markdown_negotiation.py

Tests whether a given URL supports HTTP content negotiation for `text/markdown`.

It does this by:
  1. Requesting the resource with no Accept header (baseline).
  2. Requesting the resource with `Accept: text/markdown` (and a low-quality
     fallback for other types) to see if the server actually changes its
     response based on that header.
  3. Requesting the resource with an Accept header that explicitly excludes
     `text/markdown` (as a sanity check / control).
  4. Comparing the resulting Content-Type headers (and, as a secondary
     signal, the response bodies) across these requests, and checking for a
     `Vary: Accept` header, which servers are supposed to send when their
     response depends on the Accept header.

Usage:
    python check_markdown_negotiation.py <url>

Exit codes:
    0 - negotiation for text/markdown appears to be supported
    1 - negotiation for text/markdown does NOT appear to be supported
    2 - error contacting the server
"""

import sys
import argparse

try:
    import requests
except ImportError:
    sys.exit(
        "This script requires the 'requests' library.\n"
        "Install it with: pip install requests"
    )

def fetch(url, accept_header, timeout=10):
    """Perform a GET request with a given Accept header, return response or None."""
    headers = {}
    if accept_header is not None:
        headers["Accept"] = accept_header
    try:
        resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
        return resp
    except requests.RequestException as e:
        print(f"Error fetching {url} with Accept={accept_header!r}: {e}", file=sys.stderr)
        return None

def content_type(resp):
    return resp.headers.get("Content-Type", "").split(";")[0].strip().lower()

def main():
    parser = argparse.ArgumentParser(description="Test text/markdown content negotiation for a URL.")
    parser.add_argument("url", help="URL to test")
    parser.add_argument("--timeout", type=float, default=10, help="Request timeout in seconds")
    args = parser.parse_args()

    url = args.url

    print(f"Testing content negotiation for text/markdown at: {url}\n")

    # 1. Baseline request, no Accept header override (requests default is */*).
    baseline = fetch(url, None, args.timeout)

    # 2. Request explicitly preferring text/markdown.
    markdown_pref = fetch(url, "text/markdown, text/html;q=0.5, */*;q=0.1", args.timeout)

    # 3. Control request that excludes text/markdown entirely.
    no_markdown = fetch(url, "text/html, application/json, */*;q=0.1", args.timeout)

    if baseline is None or markdown_pref is None or no_markdown is None:
        print("Could not complete all requests; see errors above.")
        sys.exit(2)

    baseline_ct = content_type(baseline)
    markdown_ct = content_type(markdown_pref)
    no_markdown_ct = content_type(no_markdown)
    vary_header = markdown_pref.headers.get("Vary", "")

    print(f"Baseline request       -> status {baseline.status_code}, Content-Type: {baseline_ct or '(none)'}")
    print(f"Accept: text/markdown  -> status {markdown_pref.status_code}, Content-Type: {markdown_ct or '(none)'}")
    print(f"Accept without md pref -> status {no_markdown.status_code}, Content-Type: {no_markdown_ct or '(none)'}")
    print(f"Vary header on md req  -> {vary_header or '(none)'}\n")

    got_markdown_type = markdown_ct in ("text/markdown", "text/x-markdown")
    body_differs = markdown_pref.text != no_markdown.text
    vary_mentions_accept = "accept" in vary_header.lower()

    supported = got_markdown_type and (body_differs or markdown_ct != no_markdown_ct)

    print("Result:")
    if got_markdown_type:
        print("  ✔ Server returned a text/markdown Content-Type when asked for it.")
    else:
        print("  ✘ Server did NOT return a text/markdown Content-Type when asked for it.")

    if body_differs or markdown_ct != no_markdown_ct:
        print("  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).")
    else:
        print("  ✘ Response looks the same regardless of Accept header.")

    if vary_mentions_accept:
        print("  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.")
    else:
        print("  ~ No 'Vary: Accept' header found (not required, but good practice when negotiating).")

    print()
    if supported:
        print("CONCLUSION: The URL appears to support content negotiation for text/markdown.")
        sys.exit(0)
    else:
        print("CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.")
        sys.exit(1)

if __name__ == "__main__":
    main()

So I test it:

(nix) Playground $ python content_negotiation.py https://example.com/
Testing content negotiation for text/markdown at: https://example.com/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 200, Content-Type: text/html
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> (none)

Result:
  ✘ Server did NOT return a text/markdown Content-Type when asked for it.
  ✘ Response looks the same regardless of Accept header.
  ~ No 'Vary: Accept' header found (not required, but good practice when negotiating).

CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.
(nix) Playground $ python content_negotiation.py https://mikelev.in/
Testing content negotiation for text/markdown at: https://mikelev.in/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 200, Content-Type: text/html
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept-Encoding, Accept

Result:
  ✘ Server did NOT return a text/markdown Content-Type when asked for it.
  ✘ Response looks the same regardless of Accept header.
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.
(nix) Playground $ 

But look at my system. It totally should be serving markdown. What’s up?

Claude Sonnet 5 Medium: That Vary: Accept header showing up on mikelev.in but nothing else changing is the tell — it’s not a bug in your test script, it’s a bug in the Nginx config itself.

Look closely at your configuration.nix. You define the sensor correctly:

map $http_accept $serve_markdown {
    default 0;
    "~*text/markdown" 1;
}

That variable $serve_markdown gets computed on every request… and then is never used again except to feed the log format. The actual location "/" block is:

locations."/" = {
  extraConfig = ''
    add_header Vary "Accept" always;

    if ($new_uri) {
        return 301 $new_uri;
    }
  '';
};

There’s no if ($serve_markdown), no rewrite, no try_files alternate — nothing that actually changes what gets served. You’re unconditionally telling clients “hey, my response varies based on Accept” (hence the header appearing), but then serving the exact same index.html regardless of what they asked for. It’s a promise the config never keeps.

Meanwhile your post-receive hook does put the raw markdown in place:

cp "$post" "_site${PERMALINK}index.md"

So the file index.md sits right next to index.html in _site$PERMALINK/, fully capable of being served — Nginx just never reaches for it.

The fix is to actually branch on $serve_markdown inside that location block, something like:

locations."/" = {
  extraConfig = ''
    add_header Vary "Accept" always;

    if ($new_uri) {
        return 301 $new_uri;
    }

    if ($serve_markdown) {
        rewrite ^(.*)/$ $1/index.md break;
        rewrite ^(.*[^/])$ $1/index.md break;
    }

    try_files $uri $uri/index.md $uri/index.html =404;
  '';
};

(The two rewrite lines handle both /some-post/ and /some-post forms; you’ll want to test both against your actual permalink style.) You’d also probably want to explicitly set add_header Content-Type text/markdown when serving the .md variant, since Nginx’s default mime.types won’t know .md should be text/markdown unless you add that mapping too:

types {
    text/markdown md;
}

Once that’s wired up, re-running your script against mikelev.in should show the Content-Type: text/markdown swap and the body actually differing — not just the Vary header lying about it.

MikeLev.in: Okay, how do I wire that up? Is everything in context to write the patches?

When Verification Collides with Reality

Claude Sonnet 5 Medium: Context verified — I have remotes/honeybot/nixos/configuration.nix (the target file), nixops.sh (the deploy script), and the apply.py/xp.py patch harness. That’s everything needed to wire this up and ship it.

Two small changes to that file: add the .md MIME type mapping, and actually branch on $serve_markdown in the / location instead of just declaring Vary: Accept and doing nothing with it.

One design note baked into the patch: try_files tries index.html before index.md for normal (non-rewritten) requests, so a human browser never accidentally gets served markdown. The $serve_markdown rewrite fires first and rewrites the URI straight to index.md, so by the time try_files runs for a markdown-negotiated request, $uri already points at the .md file and matches on the first try.

Target: remotes/honeybot/nixos/configuration.nix
[[[SEARCH]]]
      # 1. The Missing Map: Define $serve_markdown based on the Accept header
      map $http_accept $serve_markdown {
          default 0;
          "~*text/markdown" 1;
      }
[[[DIVIDER]]]
      # 1. The Missing Map: Define $serve_markdown based on the Accept header
      map $http_accept $serve_markdown {
          default 0;
          "~*text/markdown" 1;
      }

      # 1b. Teach Nginx that .md is text/markdown. mime.types has no entry for
      # it, so without this the served file falls back to
      # application/octet-stream no matter what the negotiation logic below does.
      types {
          text/markdown md;
      }
[[[REPLACE]]]

Target: remotes/honeybot/nixos/configuration.nix
[[[SEARCH]]]
      # 2. THE SWITCH: Route to the .md file if the sensor fired
      locations."/" = {

        # In virtualHosts."mikelev.in" locations."/" block
        extraConfig = ''
          add_header Vary "Accept" always;

          if ($new_uri) {
              return 301 $new_uri;
          }
        '';
      };
[[[DIVIDER]]]
      # 2. THE SWITCH: Route to the .md file if the sensor fired
      locations."/" = {

        # In virtualHosts."mikelev.in" locations."/" block
        extraConfig = ''
          add_header Vary "Accept" always;

          if ($new_uri) {
              return 301 $new_uri;
          }

          # THE ACTUAL SWITCH: rewrite to the sibling index.md when the
          # Accept-header sensor ($serve_markdown, from appendHttpConfig)
          # fired. Two rewrite forms cover both permalink shapes: a trailing
          # slash ("/some-post/") and a bare path ("/some-post").
          if ($serve_markdown) {
              rewrite ^(.*)/$ $1/index.md break;
              rewrite ^(.*[^/])$ $1/index.md break;
          }

          # index.html BEFORE index.md: a normal (non-negotiated) request
          # always falls through to html. A negotiated request has already
          # been rewritten above so $uri hits the .md file on the first check.
          try_files $uri $uri/index.html $uri/index.md =404;
        '';
      };
[[[REPLACE]]]

To ship it, run xp (or apply.py) on this patch, then redeploy with your existing publish alias — it already runs nixops.sh and the remote nixos-rebuild switch in one shot. After that, re-run your check_markdown_negotiation.py against https://mikelev.in/ — you should now see Content-Type: text/markdown and a differing body when Accept: text/markdown is sent.

MikeLev.in: Okay, I apply the patches:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/nixos/configuration.nix'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/nixos/configuration.nix b/remotes/honeybot/nixos/configuration.nix
index b03c9bad..cff387a2 100644
--- a/remotes/honeybot/nixos/configuration.nix
+++ b/remotes/honeybot/nixos/configuration.nix
@@ -222,6 +222,13 @@
           "~*text/markdown" 1;
       }
 
+      # 1b. Teach Nginx that .md is text/markdown. mime.types has no entry for
+      # it, so without this the served file falls back to
+      # application/octet-stream no matter what the negotiation logic below does.
+      types {
+          text/markdown md;
+      }
+
       # 2. THE GHOST CATCHER: Load the dynamic 404 ledger into RAM
       map $uri $new_uri {
           include /home/mike/www/mikelev.in/_site/redirects.map;
(nix) pipulate $ m
📝 Committing: chore: Add mime type for markdown files in Nix configuration
[main 5eac2778] chore: Add mime type for markdown files in Nix configuration
 1 file changed, 7 insertions(+)
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/nixos/configuration.nix'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/nixos/configuration.nix b/remotes/honeybot/nixos/configuration.nix
index cff387a2..642a5bac 100644
--- a/remotes/honeybot/nixos/configuration.nix
+++ b/remotes/honeybot/nixos/configuration.nix
@@ -255,6 +255,20 @@
           if ($new_uri) {
               return 301 $new_uri;
           }
+
+          # THE ACTUAL SWITCH: rewrite to the sibling index.md when the
+          # Accept-header sensor ($serve_markdown, from appendHttpConfig)
+          # fired. Two rewrite forms cover both permalink shapes: a trailing
+          # slash ("/some-post/") and a bare path ("/some-post").
+          if ($serve_markdown) {
+              rewrite ^(.*)/$ $1/index.md break;
+              rewrite ^(.*[^/])$ $1/index.md break;
+          }
+
+          # index.html BEFORE index.md: a normal (non-negotiated) request
+          # always falls through to html. A negotiated request has already
+          # been rewritten above so $uri hits the .md file on the first check.
+          try_files $uri $uri/index.html $uri/index.md =404;
         '';
       };
       # THE JAVASCRIPT TRAPDOOR
(nix) pipulate $ m
📝 Committing: chore: Refactor rewrite rules for markdown serving in nixos config
[main 61b12bcb] chore: Refactor rewrite rules for markdown serving in nixos config
 1 file changed, 14 insertions(+)
(nix) pipulate $ git push
Enumerating objects: 17, done.
Counting objects: 100% (17/17), done.
Delta compression using up to 48 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (12/12), 1.44 KiB | 1.44 MiB/s, done.
Total 12 (delta 6), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (6/6), completed with 3 local objects.
To github.com:pipulate/pipulate.git
   1df18385..61b12bcb  main -> main
(nix) pipulate $

And now I redeploy just the Honeybot Nginx server Infrastructure as Code, which is another way of saying “rebuild Nix” with my nixops process:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ nixops
🚀 Syncing Hooks...
post-receive                                                                                                                                       100% 2311   502.8KB/s   00:00    
🚀 Syncing Scripts (New Location)...
sending incremental file list
deleting __pycache__/score.cpython-313.pyc
deleting __pycache__/forest.cpython-313.pyc
deleting __pycache__/db.cpython-313.pyc
deleting __pycache__/content_loader.cpython-313.pyc
__pycache__/

sent 703 bytes  received 171 bytes  1,748.00 bytes/sec
total size is 175,036  speedup is 200.27
sending incremental file list

sent 68 bytes  received 12 bytes  53.33 bytes/sec
total size is 71,338  speedup is 891.73
🚀 Syncing NixOS Config...
sending incremental file list
configuration.nix

sent 2,662 bytes  received 197 bytes  5,718.00 bytes/sec
total size is 20,202  speedup is 7.07
✅ Sync Complete.
   To apply NixOS config: ssh -t mike@[REDACTED_IP] 'sudo cp ~/nixos-config-staged/* /etc/nixos/ && sudo nixos-rebuild switch'
(nix) pipulate $ ssh -t mike@[REDACTED_IP] 'sudo cp ~/nixos-config-staged/* /etc/nixos/ && sudo nixos-rebuild switch'
[sudo] password for mike: 
building the system configuration...
evaluation warning: The option `services.logind.lidSwitchExternalPower' defined in `/etc/nixos/configuration.nix' has been renamed to `services.logind.settings.Login.HandleLidSwitchExternalPower'.
evaluation warning: The option `services.logind.lidSwitch' defined in `/etc/nixos/configuration.nix' has been renamed to `services.logind.settings.Login.HandleLidSwitch'.
evaluation warning: The option `services.xserver.displayManager.gdm.wayland' defined in `/etc/nixos/configuration.nix' has been renamed to `services.displayManager.gdm.wayland'.
evaluation warning: The option `services.xserver.displayManager.gdm.enable' defined in `/etc/nixos/configuration.nix' has been renamed to `services.displayManager.gdm.enable'.
these 22 derivations will be built:
  /nix/store/0n5r0kddp9c0swa2vn8ssgg75hqp6m06-unit-sleep.target-disabled.drv
  /nix/store/1n01wp078wd0jk574px8wa5d15h9r6kw-stage-2-init.sh.drv
  /nix/store/4i717rki4x4qi400f291vw12qf1xi8wn-boot.json.drv
  /nix/store/4kp1icz5g7py6fd5d9rvryq5dfgvlkbs-nginx.conf.drv
  /nix/store/8y4vn0r0jwxxh6hclxq6nra10bxrjab1-builder.pl.drv
  /nix/store/9ph3i21c3hh19h8jlgk8kgn7yzd6lavv-unit-console-getty.service-disabled.drv
  /nix/store/akc5niqckay2w54cw80my96rzvvqca7m-perl-5.40.0-env.drv
  /nix/store/jxfyi7i6rf1bq46q492xq7s87m689mpv-unit-pipewire-pulse.service-disabled.drv
  /nix/store/kws2zpry7ck65m16rld9lbiqmqds3mm3-unit-gdm.service-disabled.drv
  /nix/store/mqjhlfli9chchafskj83xz3kdaz7fkn7-unit-suspend.target-disabled.drv
  /nix/store/rxcsin41hxh5i1klya55xrm5xpxcfchi-unit-pipewire.service-disabled.drv
  /nix/store/snd20f9fqhs5nwf3bdql2djc51ssmkiv-unit-wireplumber.service-disabled.drv
  /nix/store/vg524m7j68s0kcsnh6dr1d878gp8ndv1-unit-hibernate.target-disabled.drv
  /nix/store/wajacvwyd18yrzs5aa0j0kgspqvrq377-unit-pipewire-pulse.socket-disabled.drv
  /nix/store/xgs3vk0rz04w9fha3gpshyxwvqvhbf9f-unit-pipewire.socket-disabled.drv
  /nix/store/xzarjdjqkgqmrfjfl76af7jynz87qg22-unit-hybrid-sleep.target-disabled.drv
  /nix/store/yi6xs8cixvsdzas35ijvilsz6zda2vvg-unit-script-nginx-pre-start.drv
  /nix/store/y9nc47490sbvhdyfhmg14w5w828xysj4-unit-nginx.service.drv
  /nix/store/bp8wnsafa7w8vckgnn7mdy7w8n9bmzy4-system-units.drv
  /nix/store/raigkqwbp4hv2hq5phkyrr7imvy27bz1-check-sshd-config.drv
  /nix/store/s9k4qrqz66lfqqj0rs0fvxjbcal3w3dq-etc.drv
  /nix/store/ll8w9ldp0hrlgqrk2fm1r98ib89g48ap-nixos-system-honeybot-26.05pre913595.c6245e83d836.drv
these 12 paths will be fetched (0.31 MiB download, 1.85 MiB unpacked):
  /nix/store/3lq26d4nmwgh8cy0h55spgb3bxc7l504-die-hook
  /nix/store/k1wj6h54zcv9ddaws2g9ndy2bzrgjzrp-ensure-all-wrappers-paths-exist
  /nix/store/bbb9bz321p47lj6gnm58arxbwvgzy072-gixy-0.1.21
  /nix/store/6xgfl97mg6mpzaqf3z61a76c8savhg45-lndir-1.0.5
  /nix/store/wxjcx5fis4bygw7bywg2jkjs4cliw3jd-make-binary-wrapper-hook
  /nix/store/6l9mndwn2z52njn5shabyqc3jr3bxmla-make-shell-wrapper-hook
  /nix/store/98hzkapp8ah9dgr2lfkpaxgxkimgiflr-openssh-10.2p1-dev
  /nix/store/ckjj89mbj68kmg0bz8a6wmadwv8vd11b-python3.13-cached-property-2.0.1
  /nix/store/r4zwjx6sadxs11mj3dvbjqnd19v6vmbs-python3.13-configargparse-1.7.1
  /nix/store/vmzbgr569mxslxc27a860d5nzvn1vl7v-python3.13-pyparsing-2.4.7
  /nix/store/br3nr5ymp1p8k9gn9zljmbnsksikj98l-stdenv-linux
  /nix/store/s90xqi84f06y5mpncziw0993xiwwl5wn-xkb-validated
copying path '/nix/store/k1wj6h54zcv9ddaws2g9ndy2bzrgjzrp-ensure-all-wrappers-paths-exist' from 'https://cache.nixos.org'...
copying path '/nix/store/s90xqi84f06y5mpncziw0993xiwwl5wn-xkb-validated' from 'https://cache.nixos.org'...
copying path '/nix/store/3lq26d4nmwgh8cy0h55spgb3bxc7l504-die-hook' from 'https://cache.nixos.org'...
copying path '/nix/store/br3nr5ymp1p8k9gn9zljmbnsksikj98l-stdenv-linux' from 'https://cache.nixos.org'...
copying path '/nix/store/98hzkapp8ah9dgr2lfkpaxgxkimgiflr-openssh-10.2p1-dev' from 'https://cache.nixos.org'...
copying path '/nix/store/6xgfl97mg6mpzaqf3z61a76c8savhg45-lndir-1.0.5' from 'https://cache.nixos.org'...
copying path '/nix/store/ckjj89mbj68kmg0bz8a6wmadwv8vd11b-python3.13-cached-property-2.0.1' from 'https://cache.nixos.org'...
copying path '/nix/store/r4zwjx6sadxs11mj3dvbjqnd19v6vmbs-python3.13-configargparse-1.7.1' from 'https://cache.nixos.org'...
copying path '/nix/store/vmzbgr569mxslxc27a860d5nzvn1vl7v-python3.13-pyparsing-2.4.7' from 'https://cache.nixos.org'...
copying path '/nix/store/wxjcx5fis4bygw7bywg2jkjs4cliw3jd-make-binary-wrapper-hook' from 'https://cache.nixos.org'...
copying path '/nix/store/6l9mndwn2z52njn5shabyqc3jr3bxmla-make-shell-wrapper-hook' from 'https://cache.nixos.org'...
building '/nix/store/4i717rki4x4qi400f291vw12qf1xi8wn-boot.json.drv'...
building '/nix/store/8y4vn0r0jwxxh6hclxq6nra10bxrjab1-builder.pl.drv'...
building '/nix/store/1n01wp078wd0jk574px8wa5d15h9r6kw-stage-2-init.sh.drv'...
building '/nix/store/9ph3i21c3hh19h8jlgk8kgn7yzd6lavv-unit-console-getty.service-disabled.drv'...
building '/nix/store/kws2zpry7ck65m16rld9lbiqmqds3mm3-unit-gdm.service-disabled.drv'...
building '/nix/store/vg524m7j68s0kcsnh6dr1d878gp8ndv1-unit-hibernate.target-disabled.drv'...
building '/nix/store/xzarjdjqkgqmrfjfl76af7jynz87qg22-unit-hybrid-sleep.target-disabled.drv'...
building '/nix/store/jxfyi7i6rf1bq46q492xq7s87m689mpv-unit-pipewire-pulse.service-disabled.drv'...
Running phase: patchPhase
Running phase: updateAutotoolsGnuConfigScriptsPhase
Running phase: configurePhase
no configure script, doing nothing
Running phase: buildPhase
Running phase: checkPhase
Running phase: installPhase
no Makefile or custom installPhase, doing nothing
Running phase: fixupPhase
shrinking RPATHs of ELF executables and libraries in /nix/store/4z0grds97rmripxps6b0kby1fr3g35cp-builder.pl
checking for references to /build/ in /nix/store/4z0grds97rmripxps6b0kby1fr3g35cp-builder.pl...
patching script interpreter paths in /nix/store/4z0grds97rmripxps6b0kby1fr3g35cp-builder.pl
Running phase: patchPhase
Running phase: updateAutotoolsGnuConfigScriptsPhase
Running phase: configurePhase
no configure script, doing nothing
Running phase: buildPhase
Running phase: checkPhase
Running phase: installPhase
no Makefile or custom installPhase, doing nothing
Running phase: fixupPhase
shrinking RPATHs of ELF executables and libraries in /nix/store/22k2cj5ciy3mb7y8xhj6l5027sck6kdk-stage-2-init.sh
checking for references to /build/ in /nix/store/22k2cj5ciy3mb7y8xhj6l5027sck6kdk-stage-2-init.sh...
patching script interpreter paths in /nix/store/22k2cj5ciy3mb7y8xhj6l5027sck6kdk-stage-2-init.sh
copying path '/nix/store/bbb9bz321p47lj6gnm58arxbwvgzy072-gixy-0.1.21' from 'https://cache.nixos.org'...
building '/nix/store/wajacvwyd18yrzs5aa0j0kgspqvrq377-unit-pipewire-pulse.socket-disabled.drv'...
building '/nix/store/rxcsin41hxh5i1klya55xrm5xpxcfchi-unit-pipewire.service-disabled.drv'...
building '/nix/store/xgs3vk0rz04w9fha3gpshyxwvqvhbf9f-unit-pipewire.socket-disabled.drv'...
building '/nix/store/0n5r0kddp9c0swa2vn8ssgg75hqp6m06-unit-sleep.target-disabled.drv'...
building '/nix/store/mqjhlfli9chchafskj83xz3kdaz7fkn7-unit-suspend.target-disabled.drv'...
building '/nix/store/raigkqwbp4hv2hq5phkyrr7imvy27bz1-check-sshd-config.drv'...
building '/nix/store/snd20f9fqhs5nwf3bdql2djc51ssmkiv-unit-wireplumber.service-disabled.drv'...
building '/nix/store/4kp1icz5g7py6fd5d9rvryq5dfgvlkbs-nginx.conf.drv'...
building '/nix/store/akc5niqckay2w54cw80my96rzvvqca7m-perl-5.40.0-env.drv'...
created 183 symlinks in user environment
[context]       INFO    Can't find variable 'new_uri'

==================== Results ===================
No issues found.

==================== Summary ===================
Total issues:
    Unspecified: 0
    Low: 0
    Medium: 0
    High: 0

building '/nix/store/yi6xs8cixvsdzas35ijvilsz6zda2vvg-unit-script-nginx-pre-start.drv'...
building '/nix/store/y9nc47490sbvhdyfhmg14w5w828xysj4-unit-nginx.service.drv'...
building '/nix/store/bp8wnsafa7w8vckgnn7mdy7w8n9bmzy4-system-units.drv'...
building '/nix/store/s9k4qrqz66lfqqj0rs0fvxjbcal3w3dq-etc.drv'...
building '/nix/store/ll8w9ldp0hrlgqrk2fm1r98ib89g48ap-nixos-system-honeybot-26.05pre913595.c6245e83d836.drv'...
activating the configuration...
setting up /etc...
reloading user units for mike...
restarting sysinit-reactivation.target
restarting the following units: nginx.service
the following new units were started: NetworkManager-dispatcher.service
Done. The new configuration is /nix/store/iscnpk9xib69fb3dl1vzp8c0x8q1bm6l-nixos-system-honeybot-26.05pre913595.c6245e83d836
Connection to [REDACTED_IP] closed.
(nix) pipulate $

And now I can run the test again that checks this:

(nix) Playground $ python content_negotiation.py https://mikelev.in/
Testing content negotiation for text/markdown at: https://mikelev.in/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 404, Content-Type: text/html
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept

Result:
  ✘ Server did NOT return a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.
(nix) Playground $

Well that seems closer, but I’m upgrading this challenge to the Fable 5 model. Fable 5, what’s up?

Fable 5 High: Context verified. And good news first: your Nginx config is now working. That 404 is the proof, not the problem.

Walk the request through the config: Accept: text/markdown on / fires the sensor, the rewrite turns / into /index.md, and try_files goes looking for _site/index.md… which doesn’t exist. Look at your post-receive hook — the markdown-twin loop only iterates _posts/*.md:

for post in _posts/*.md; do

Your homepage isn’t a post. It’s the root-level index.md in the trimnoir repo, so it never gets a markdown twin copied into _site/. The negotiation switch flipped correctly and reached for a file that was never published. Every article URL should already be negotiating perfectly right now.

Cheapest falsifying probe (run this before patching anything):

curl -sI -H "Accept: text/markdown" https://mikelev.in/$(ls -t ~/repos/trimnoir/_posts/*.md | head -1 | xargs grep -m1 '^permalink:' | sed 's/permalink:[[:space:]]*//; s/["'"'"' ]//g' | sed 's|^/||')

Or just hand-pick any recent post permalink: curl -sI -H "Accept: text/markdown" https://mikelev.in/<some-post-slug>/. If you see 200 with Content-Type: text/markdown, the config is fully vindicated and the only gap is root-level pages.

The fix belongs in the hook, not Nginx: publish markdown twins for root-level pages too. This handles index.md (permalink defaults to / if none declared) and any page like about.md that declares its own permalink:

Target: remotes/honeybot/hooks/post-receive
[[[SEARCH]]]
  for post in _posts/*.md; do
    PERMALINK=$(grep -m 1 '^permalink:' "$post" | sed -e 's/^permalink:[[:space:]]*//' -e 's/["'\''[:space:]]//g')
    if [ -n "$PERMALINK" ]; then
      mkdir -p "_site$PERMALINK"
      cp "$post" "_site${PERMALINK}index.md"
      sed -i -e 's/{ % raw % }//g' -e 's/{ % endraw % }//g' "_site${PERMALINK}index.md"
    fi
  done
[[[DIVIDER]]]
  for post in _posts/*.md; do
    PERMALINK=$(grep -m 1 '^permalink:' "$post" | sed -e 's/^permalink:[[:space:]]*//' -e 's/["'\''[:space:]]//g')
    if [ -n "$PERMALINK" ]; then
      mkdir -p "_site$PERMALINK"
      cp "$post" "_site${PERMALINK}index.md"
      sed -i -e 's/{ % raw % }//g' -e 's/{ % endraw % }//g' "_site${PERMALINK}index.md"
    fi
  done

  # THE HOMEPAGE TWIN: root-level pages (index.md, about.md, ...) live outside
  # _posts/, so the loop above never publishes their markdown. Without this,
  # a negotiated Accept: text/markdown request to "/" rewrites to /index.md
  # and 404s. index.md with no permalink frontmatter defaults to "/".
  for page in *.md; do
    [ -f "$page" ] || continue
    PERMALINK=$(grep -m 1 '^permalink:' "$page" | sed -e 's/^permalink:[[:space:]]*//' -e 's/["'\''[:space:]]//g')
    if [ -z "$PERMALINK" ] && [ "$page" = "index.md" ]; then
      PERMALINK="/"
    fi
    if [ -n "$PERMALINK" ]; then
      mkdir -p "_site$PERMALINK"
      cp "$page" "_site${PERMALINK}index.md"
      sed -i -e 's/{ % raw % }//g' -e 's/{ % endraw % }//g' "_site${PERMALINK}index.md"
    fi
  done
[[[REPLACE]]]

Deployment note: this is a hook change, not a NixOS change — no nixos-rebuild needed. After xp/app applies it, run nixops to scp the hook into place, then ring the bell with an empty push (force alias) so the hook re-runs and materializes _site/index.md. Then:

python content_negotiation.py https://mikelev.in/

should finally show all three checkmarks: 200, Content-Type: text/markdown, differing body, Vary: Accept. And your script will have earned its exit code 0 the honest way — by catching two real bugs (the missing switch, then the missing file) on its way there.

MikeLev.in: Okay, I do the cheap falsifying probe:

(nix) pipulate $ curl -sI -H "Accept: text/markdown" https://mikelev.in/$(ls -t ~/repos/trimnoir/_posts/*.md | head -1 | xargs grep -m1 '^permalink:' | sed 's/permalink:[[:space:]]*//; s/["'"'"' ]//g' | sed 's|^/||')
HTTP/2 200 
server: nginx
date: Mon, 06 Jul 2026 19:40:51 GMT
content-type: text/markdown
content-length: 121774
last-modified: Mon, 06 Jul 2026 13:58:01 GMT
vary: Accept-Encoding
etag: "6a4bb469-1dbae"
vary: Accept
accept-ranges: bytes

(nix) pipulate $

Okay, that’s good. Next? A patch.

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/hooks/post-receive'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/hooks/post-receive b/remotes/honeybot/hooks/post-receive
index ecffefc7..e0d1f397 100644
--- a/remotes/honeybot/hooks/post-receive
+++ b/remotes/honeybot/hooks/post-receive
@@ -39,6 +39,23 @@ nix develop .#quiet --command bash << 'EOF'
     fi
   done
 
+  # THE HOMEPAGE TWIN: root-level pages (index.md, about.md, ...) live outside
+  # _posts/, so the loop above never publishes their markdown. Without this,
+  # a negotiated Accept: text/markdown request to "/" rewrites to /index.md
+  # and 404s. index.md with no permalink frontmatter defaults to "/".
+  for page in *.md; do
+    [ -f "$page" ] || continue
+    PERMALINK=$(grep -m 1 '^permalink:' "$page" | sed -e 's/^permalink:[[:space:]]*//' -e 's/["'\''[:space:]]//g')
+    if [ -z "$PERMALINK" ] && [ "$page" = "index.md" ]; then
+      PERMALINK="/"
+    fi
+    if [ -n "$PERMALINK" ]; then
+      mkdir -p "_site$PERMALINK"
+      cp "$page" "_site${PERMALINK}index.md"
+      sed -i -e 's/{ % raw % }//g' -e 's/{ % endraw % }//g' "_site${PERMALINK}index.md"
+    fi
+  done
+
   # 2. Run the Batch Surgeon ONCE
   python3 /home/mike/www/mikelev.in/scripts/dye_injector_v2.py "/home/mike/www/mikelev.in/_site"
 EOF
(nix) pipulate $ m
📝 Committing: chore: Fix index.md publishing logic in honeybot hook 
[main a4a3a0f9] chore: Fix index.md publishing logic in honeybot hook
 1 file changed, 17 insertions(+)
(nix) pipulate $ git push
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 48 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (6/6), 884 bytes | 884.00 KiB/s, done.
Total 6 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:pipulate/pipulate.git
   d32ede26..a4a3a0f9  main -> main
(nix) pipulate $

And that’s not a NixOps sort of thing. This is a GitOps sort of thing and I have two alias choices here, gitops or force:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ force
[main 25ad1de] retry
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 180 bytes | 180.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: 🎯 [Hook] Received Push. Deploying...
remote: Already on 'main'
remote: 🔨 [Hook] Starting Hybrid Build...
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.nokogiri --use-system-libraries` instead.
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.ffi --enable-system-libffi` instead.
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.eventmachine --with-cflags=-I/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include` instead.
remote: Linux detected. Skipping Neovim setup as it's not needed.
remote: /tmp/nix-shell.DztQKq: line 2715: xc: command not found
remote: /tmp/nix-shell.DztQKq: line 2716: xcp: command not found
remote: /tmp/nix-shell.DztQKq: line 2717: xv: command not found
remote: Jekyll environment ready (quiet mode).
remote: Run 'jes' to start the server manually.
remote: 🏗️  Jekyll Build...
remote: Configuration file: /home/mike/www/mikelev.in/_config.yml
remote:             Source: /home/mike/www/mikelev.in
remote:        Destination: /home/mike/www/mikelev.in/_site
remote:  Incremental build: disabled. Enable with --incremental
remote:       Generating... 
remote:                     done in 7.478 seconds.
remote:  Auto-regeneration: disabled. Use --watch to enable.
remote: 📄 Publishing Source Markdown (Chisel-Strike)...
remote: ✨ Python Scalpel: Successfully dyed 1282 articles in one pass.
remote: 🗺️  Deploying Nginx Redirect Map...
remote: 🔒 [Hook] Fixing Permissions...
remote: 🔔 [Hook] Ringing the breaking-news bell...
remote: ✅ [Hook] Deployment Complete. Site is Live.
To [REDACTED_IP]:~/git/mikelev.in.git
   6adc0197e..25ad1debe  main -> main
(nix) pipulate $

And I check:

(nix) Playground $ python content_negotiation.py https://mikelev.in/
Testing content negotiation for text/markdown at: https://mikelev.in/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 404, Content-Type: text/html
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept

Result:
  ✘ Server did NOT return a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.
(nix) Playground $

So that did not work. I have my publish pipeline that does both NixOps and GitOps in one go so I check that:

(nix) pipulate $ publish "force"
🚀 [1/3] Payload Delivery: Committing and Pushing /home/mike/repos/trimnoir...
On branch main
Your branch is up to date with 'home/main'.

nothing to commit, working tree clean
Everything up-to-date
🚀 [2/3] Infrastructure: Synchronizing Server Configurations...
🚀 Syncing Hooks...
post-receive                                                                                                                                                                                                                                                                                100% 3080   613.1KB/s   00:00    
🚀 Syncing Scripts (New Location)...
sending incremental file list
deleting aquarium_tui.py
deleting aquarium.py
./

sent 703 bytes  received 50 bytes  1,506.00 bytes/sec
total size is 175,036  speedup is 232.45
sending incremental file list

sent 68 bytes  received 12 bytes  160.00 bytes/sec
total size is 71,338  speedup is 891.73
🚀 Syncing NixOS Config...
sending incremental file list

sent 119 bytes  received 12 bytes  262.00 bytes/sec
total size is 20,202  speedup is 154.21
✅ Sync Complete.
   To apply NixOS config: ssh -t mike@[REDACTED_IP] 'sudo cp ~/nixos-config-staged/* /etc/nixos/ && sudo nixos-rebuild switch'
🚀 [3/4] The Capstone: Rebuilding Nginx Routes...
[sudo] password for mike: 
building the system configuration...
evaluation warning: The option `services.logind.lidSwitchExternalPower' defined in `/etc/nixos/configuration.nix' has been renamed to `services.logind.settings.Login.HandleLidSwitchExternalPower'.
evaluation warning: The option `services.logind.lidSwitch' defined in `/etc/nixos/configuration.nix' has been renamed to `services.logind.settings.Login.HandleLidSwitch'.
evaluation warning: The option `services.xserver.displayManager.gdm.wayland' defined in `/etc/nixos/configuration.nix' has been renamed to `services.displayManager.gdm.wayland'.
evaluation warning: The option `services.xserver.displayManager.gdm.enable' defined in `/etc/nixos/configuration.nix' has been renamed to `services.displayManager.gdm.enable'.
activating the configuration...
setting up /etc...
reloading user units for mike...
restarting sysinit-reactivation.target
the following new units were started: NetworkManager-dispatcher.service
Done. The new configuration is /nix/store/iscnpk9xib69fb3dl1vzp8c0x8q1bm6l-nixos-system-honeybot-26.05pre913595.c6245e83d836
Connection to [REDACTED_IP] closed.
⏭️  [4/4] Skipped — stream.py left running. Pass --reboot to force the restart now.
✅ Atomic Deployment Complete (stream untouched).
(nix) pipulate $

And still!

(nix) Playground $ python content_negotiation.py https://mikelev.in/
Testing content negotiation for text/markdown at: https://mikelev.in/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 404, Content-Type: text/html
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept

Result:
  ✘ Server did NOT return a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL does NOT appear to support content negotiation for text/markdown.
(nix) Playground $

What’s up?

Fable 5: Context verified.

No new bug — this is a sequencing problem. Walk your own transcript in order:

  1. You patched remotes/honeybot/hooks/post-receive locally and pushed to GitHub. That does nothing to the Honeybot — the live hook lives at ~/git/mikelev.in.git/hooks/post-receive and only nixops.sh puts it there.
  2. Then you ran force. That fired the post-receive hook on Honeybot — but it was still the old hook, without the homepage-twin loop. So _site/index.md was never created.
  3. Then you ran publish "force". That finally synced the new hook (post-receive 100% 3080 — the new 3,080-byte version landed). But look at step [1/3] of that same run: nothing to commit, working tree cleanEverything up-to-date. The publish function does a plain git commit -am with no --allow-empty, so with a clean trimnoir tree nothing was pushed — and if nothing is pushed, the post-receive hook never fires.

Net result: the old hook ran, then the new hook was installed but has never executed. The fix is one command — ring the bell again now that the correct hook is in place:

force

(The force alias uses --allow-empty, which is exactly why it exists and why publish’s inner push couldn’t do the job on a clean tree.)

Cheapest falsifying probes after the hook finishes:

ssh honeybot 'ls -la ~/www/mikelev.in/_site/index.md'
curl -sI -H "Accept: text/markdown" https://mikelev.in/

If the file is there and the curl shows 200 with content-type: text/markdown, your test script should finally print all three checkmarks and exit 0.

One optional hardening note for later: publish could use git commit --allow-empty -am "$MSG" (or fall back to an empty commit when there’s nothing staged) so a config-only deploy still rings the hook. As it stands, any change that only touches hook/infra files in the pipulate repo will always need a follow-up force to actually re-run the build.

MikeLev.in: Okay, so I do another force:

(nix) pipulate $ force
[main 100e012] retry
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 180 bytes | 180.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: 🎯 [Hook] Received Push. Deploying...
remote: Already on 'main'
remote: 🔨 [Hook] Starting Hybrid Build...
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.nokogiri --use-system-libraries` instead.
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.ffi --enable-system-libffi` instead.
remote: [DEPRECATED] Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle config set build.eventmachine --with-cflags=-I/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev/include` instead.
remote: Linux detected. Skipping Neovim setup as it's not needed.
remote: /tmp/nix-shell.oOUHWM: line 2715: xc: command not found
remote: /tmp/nix-shell.oOUHWM: line 2716: xcp: command not found
remote: /tmp/nix-shell.oOUHWM: line 2717: xv: command not found
remote: Jekyll environment ready (quiet mode).
remote: Run 'jes' to start the server manually.
remote: 🏗️  Jekyll Build...
remote: Configuration file: /home/mike/www/mikelev.in/_config.yml
remote:             Source: /home/mike/www/mikelev.in
remote:        Destination: /home/mike/www/mikelev.in/_site
remote:  Incremental build: disabled. Enable with --incremental
remote:       Generating... 
remote:                     done in 7.169 seconds.
remote:  Auto-regeneration: disabled. Use --watch to enable.
remote: 📄 Publishing Source Markdown (Chisel-Strike)...
remote: ✨ Python Scalpel: Successfully dyed 1282 articles in one pass.
remote: 🗺️  Deploying Nginx Redirect Map...
remote: 🔒 [Hook] Fixing Permissions...
remote: 🔔 [Hook] Ringing the breaking-news bell...
remote: ✅ [Hook] Deployment Complete. Site is Live.
To [REDACTED_IP]:~/git/mikelev.in.git
   25ad1debe..100e012d7  main -> main
(nix) pipulate $ ssh honeybot 'ls -la ~/www/mikelev.in/_site/index.md'
-rwxr-xr-x 1 mike users 5788 Jul  6 15:52 /home/mike/www/mikelev.in/_site/index.md
(nix) pipulate $ curl -sI -H "Accept: text/markdown" https://mikelev.in/
HTTP/2 200 
server: nginx
date: Mon, 06 Jul 2026 19:52:32 GMT
content-type: text/markdown
content-length: 5788
last-modified: Mon, 06 Jul 2026 19:52:03 GMT
vary: Accept-Encoding
etag: "6a4c0763-169c"
vary: Accept
accept-ranges: bytes

(nix) pipulate $

Does that look better?

Note: I switch back to Sonnet 5 (versus Fable 5) because confirmation of a fix doesn’t take the same effort as fixing.

Claude Sonnet 5 Medium: Yes — that looks completely fixed. Walking through it:

  • _site/index.md now exists (5,788 bytes, freshly generated at 19:52:03).
  • The negotiated request returns HTTP/2 200 (not 404 anymore).
  • content-type: text/markdown is present.
  • content-length: 5788 matches the file size on disk.
  • vary: Accept is still there, confirming real negotiation rather than a static header.

If you re-run content_negotiation.py https://mikelev.in/ now, it should print all three checkmarks and exit 0 — the earlier run against the old cached state (or before the hook had actually fired) is what showed the 404. This curl was run after the successful force that produced the file, so it’s checking the corrected state directly.

Two loose threads worth closing out, both of which you already surfaced yourself in the conversation:

  1. The publish hardening note. Right now a config/hook-only change to the pipulate repo won’t trigger a rebuild on Honeybot unless you follow up with a manual force, because publish’s inner git commit -am does nothing on a clean tree. Making that commit use --allow-empty (or falling back to one when there’s nothing staged) would make publish alone sufficient for infra-only deploys — no more “wait, why didn’t this take effect” moments like the one you just walked through.

  2. Worth spot-checking a couple of article permalinks too, not just root /, now that the homepage-twin loop is live — just to confirm the original per-post negotiation (which worked in your very first cheap probe) is still intact after this hook change, e.g.:

curl -sI -H "Accept: text/markdown" https://mikelev.in/<some-post-slug>/

Want me to draft that publish patch (the --allow-empty fix) as a SEARCH/REPLACE block against flake.nix?

MikeLev.in: Okay, so I run the test one more time:

(nix) Playground $ python content_negotiation.py https://mikelev.in/
Testing content negotiation for text/markdown at: https://mikelev.in/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 200, Content-Type: text/markdown
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept-Encoding, Accept

Result:
  ✔ Server returned a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL appears to support content negotiation for text/markdown.
(nix) Playground $

And we test a few more URLs. Hmmm. My very latest article and my very first:

(nix) Playground $ python content_negotiation.py https://mikelev.in/futureproof/web-built-for-ai-honeybot-content-negotiation/
Testing content negotiation for text/markdown at: https://mikelev.in/futureproof/web-built-for-ai-honeybot-content-negotiation/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 200, Content-Type: text/markdown
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept-Encoding, Accept

Result:
  ✔ Server returned a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL appears to support content negotiation for text/markdown.
(nix) Playground $ python content_negotiation.py https://mikelev.in/futureproof/future-proof-tech-skills/
Testing content negotiation for text/markdown at: https://mikelev.in/futureproof/future-proof-tech-skills/

Baseline request       -> status 200, Content-Type: text/html
Accept: text/markdown  -> status 200, Content-Type: text/markdown
Accept without md pref -> status 200, Content-Type: text/html
Vary header on md req  -> Accept-Encoding, Accept

Result:
  ✔ Server returned a text/markdown Content-Type when asked for it.
  ✔ Response differs depending on Accept header (real negotiation, not a fixed type).
  ✔ Server sends 'Vary: Accept', signaling cache-aware content negotiation.

CONCLUSION: The URL appears to support content negotiation for text/markdown.
(nix) Playground $

Oh, that’s better. What’s the patch you’re offering for? And yes please.

Claude Sonnet 5 Medium: Good catch — that’s the sequencing bug Fable 5 flagged. Here’s the publish hardening patch: fall back to an empty commit whenever there’s nothing to stage, so an infra-only change to the pipulate repo (like the hook/config edits you just made) always triggers the deploy hook without needing a manual force follow-up.

Target: flake.nix
[[[SEARCH]]]
            echo "🚀 [1/3] Payload Delivery: Committing and Pushing $TARGET_REPO..."
            # Execute in a subshell to avoid stranding the user's terminal
            (
                cd "$TARGET_REPO" || exit 1
                git add .
                git commit -am "$MSG"
                git push
            )
[[[DIVIDER]]]
            echo "🚀 [1/3] Payload Delivery: Committing and Pushing $TARGET_REPO..."
            # Execute in a subshell to avoid stranding the user's terminal
            (
                cd "$TARGET_REPO" || exit 1
                git add .
                # THE ALWAYS-FIRES GUARANTEE: a clean tree (e.g. an infra/hook-only
                # change made in the pipulate repo, not trimnoir) means `git commit -am`
                # has nothing to commit and silently no-ops, which means no push, which
                # means the post-receive hook on Honeybot never fires. Fall back to an
                # empty commit so `publish` alone is always sufficient to trigger a deploy.
                if ! git commit -am "$MSG"; then
                    echo "ℹ️  Nothing to commit in $TARGET_REPO; creating empty commit to ring the deploy bell."
                    git commit --allow-empty -m "$MSG"
                fi
                git push
            )
[[[REPLACE]]]

Apply it the same way as before (patchcat patch | appd to confirm → m), then git push. No nixops/rebuild needed this time — it’s pure shell logic inside flake.nix’s runScript, so it takes effect the next time you nix develop (or immediately if you re-source it), and any future publish "msg" on a clean trimnoir tree will now still ring the Honeybot bell instead of silently doing nothing.

MikeLev.in: Oh, it’s an order of operations fix in an alias, so that will take a flake environment rebuild. Okay first the patch:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'flake.nix'.
(nix) pipulate $ d
diff --git a/flake.nix b/flake.nix
index ed5c380c..0f7194f5 100644
--- a/flake.nix
+++ b/flake.nix
@@ -777,7 +777,15 @@ print('AI:\n', r.ai)
             (
                 cd "$TARGET_REPO" || exit 1
                 git add .
-                git commit -am "$MSG"
+                # THE ALWAYS-FIRES GUARANTEE: a clean tree (e.g. an infra/hook-only
+                # change made in the pipulate repo, not trimnoir) means `git commit -am`
+                # has nothing to commit and silently no-ops, which means no push, which
+                # means the post-receive hook on Honeybot never fires. Fall back to an
+                # empty commit so `publish` alone is always sufficient to trigger a deploy.
+                if ! git commit -am "$MSG"; then
+                    echo "ℹ️  Nothing to commit in $TARGET_REPO; creating empty commit to ring the deploy bell."
+                    git commit --allow-empty -m "$MSG"
+                fi
                 git push
             )
             
(nix) pipulate $ m
📝 Committing: chore: Ensure deploy trigger on Honeybot even with no changes
[main 55293e55] chore: Ensure deploy trigger on Honeybot even with no changes
 1 file changed, 9 insertions(+), 1 deletion(-)
(nix) pipulate $ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 48 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 714 bytes | 714.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:pipulate/pipulate.git
   a4a3a0f9..55293e55  main -> main
(nix) pipulate $

And now the rebuild:

(nix) pipulate $ exit
exit
(sys) pipulate $ ndq
warning: updating lock file '/home/mike/repos/pipulate/flake.lock':
• Added input 'flake-utils':
    'github:numtide/flake-utils/11707dc2f618dd54ca8739b309ec4fc024de578b?narHash=sha256-l0KFg5HjrsfsO/JpG%2Br7fRrqm12kzFHyUHqHCVpMMbI%3D' (2024-11-13)
• Added input 'flake-utils/systems':
    'github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e?narHash=sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768%3D' (2023-04-09)
• Added input 'nixpkgs':
    'github:NixOS/nixpkgs/d407951447dcd00442e97087bf374aad70c04cea?narHash=sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU%2BK27sk%3D' (2026-07-05)
(nix) pipulate $ 

And so I saw something that was from a bad order-of-operations before and this fixes that? Explain it please.

Claude Sonnet 5 Medium: Good catch spotting that gap — walking through your own transcript, here’s exactly what happened and what the patch actually fixes.

The bug, step by step:

  1. You patched post-receive (the hook script) — but that’s a file in the pipulate repo. Pushing it only updates GitHub. It does nothing to Honeybot until something copies it over.
  2. nixops.sh is that “something.” It rsyncs the hook script and nginx config out to Honeybot, and separately runs nixos-rebuild switch for the config. But it does not trigger the hook — it just puts the new hook file in place.
  3. To actually run the hook, you push to trimnoir (the content repo). That push is what fires post-receive on Honeybot.

So there are really two independent pipelines:

  • GitOps (pipulate → GitHub, or trimnoir → Honeybot’s git remote, which fires the hook)
  • NixOps (rsync + rebuild, which updates what the hook does but doesn’t fire it)

Your first force fired the hook — but nixops hadn’t run yet, so Honeybot was still running the old hook (no homepage-twin logic). Confirmed 404.

Then publish "force" ran both: step [2/3] correctly rsync‘d the new hook into place… but step [1/3] was git commit -am "$MSG" on trimnoir with a clean working tree. git commit -am with nothing staged just fails silently — no commit, no push, no trigger. So the new hook got installed on Honeybot, but nothing ever asked it to run. Still 404, but for a completely different reason than the first failure.

Your manual force afterward is what finally worked, because by then the new hook was already sitting on Honeybot from publish’s nixops step, and force’s --allow-empty guaranteed an actual push happened this time.

What the patch changes: publish() now falls back to git commit --allow-empty whenever the normal commit fails (i.e., whenever the tree is clean). That closes the specific trap you hit — a publish call that syncs new infra but silently never fires the trigger because there was “nothing to commit.”

One thing worth flagging since you asked about ordering specifically: the patch doesn’t reorder publish’s three steps — it still pushes-to-trigger before running nixops. So for a change that affects the hook script itself, a single publish will still fire the hook using the stale version, with the corrected hook only ready for the next trigger. That’s fine for ordinary content pushes (the common case publish was designed for), but for hook/infra changes specifically, running nixops manually first (like you improvised) — or accepting a second cycle — is still the safe pattern. The fix you shipped guarantees publish never silently does nothing; it doesn’t yet guarantee same-cycle correctness for hook changes.

MikeLev.in: Right, right. So we’ve got a double-tap situation on infra-pushes. I get that. This helps a lot. And so did Jeff! Thank you, Jeff!

Right, right. So this is a pretty big vulnerability I fell prey to. What was it? The system was designed to do a thing, and that very thing was not being done and I didn’t know it until a coworker ran a tool against my site. Things should be designed with a series of things that are supposed to be true. The stated goal, purpose, assumptions. All that “what is knowledge” stuff, and then we need a series of tests so that it can’t be assumed.

I’m also going to earmark for the future updating the Honeybot HUDs in the YouTube live-streaming report so that I can fine-tune the message and what filter is being used on the lower-left and lower-right Textual HUDs now that I have the issue of cut-off dates when things were running certain ways like annotations on trend-lines in reporting software. Such-and-such a changed happened on this date (which happens to be July 6th, 2026) and making the reports from that date forward will ensure accurate content negotiation numbers and eliminate the dilution of the old stuff.

I’m not going to do that right now because I have client work to get onto. But I need better project… I don’t know what to call it. What is that traditionally called? I’m no engineer. I’m a code cowboy webmaster from the late 90s and mid 2000s who never lost love of the craft who is feeling very enabled by Nix insofar as the non-mutable machine that I can “cast” like a spell. And because you can bootstrap a project scrappy and then keep refining over time without battling mutation monsters, everything feels possible. But that doesn’t bar you from making mistakes; a very big one I just did undermining the premise of an experiment.

Verification as a Standing Appointment

But that’s fixed and we love these directional fixes. I’m sure I have more fixes like this to make against more blind-spots I didn’t know I had. So we bank this article and immortalize thanking Jeff. Hey Jeff, thanks! Now let’s unfurl and expand our learnings before we wrap this. And I’ll spend for the Fable 5 turn to do it.

Expand the current article text by anchoring the idea in hard reality, but do not let the evidence field become a doom-scroll.

[The article expand-and-wrap protocol was here]

Fable 5?

🎤

Fable 5 High: Context verified — and mic accepted. Here’s the expansion, treated as the closing movement of the article.


1. The Context & Hard Evidence

What happened here has a name in engineering, since you asked: it’s the gap between verification (“did we build the thing right?”) and validation (“does the thing actually do what we claimed, in the world, right now?”). The ongoing version — probing your own live system the way an outsider would — is called synthetic monitoring, and Jeff’s tool was exactly that: a synthetic probe you didn’t have to build because a colleague pointed one at you. The discipline you’re reaching for is sometimes bundled as V&V, sometimes as “continuous verification,” sometimes just as the definition of done includes a test that can fail.

The pattern — a system faithfully recording that it’s doing a thing it is not doing — is one of the best-documented failure modes in the field, and the documentation exists precisely because the repairs became famous.

GitLab’s database outage of January 31, 2017 is the canonical modern case. An engineer deleted a production database directory during replication troubleshooting, and the team then discovered that, of the five backup and replication mechanisms they believed they had, none was producing restorable backups — one had been silently failing for some time because of a version mismatch, and the failure notifications weren’t arriving. Roughly six hours of database data was lost. The primary source is GitLab’s own postmortem, published on their blog on February 10, 2017, which is also where the repair lives: they published the incident live, streamed the recovery, enumerated every broken assumption in public, and made verified restores — not “backups exist” but “restores succeed” — a tested property. The postmortem itself became the durable artifact. That’s the Provenance Corollary in action: the record of the failure is now worth more to the industry than the lost six hours ever were.

Reach further back and the pattern holds at telescope scale. The Hubble Space Telescope launched in 1990 with a primary mirror ground precisely to the wrong shape, because a measuring instrument (the reflective null corrector) had a spacing error — and when two other test instruments disagreed with it, their dissent was rationalized away. NASA’s investigation, the Allen Report (1990), found that the conflicting evidence had been available before launch. The repair is the part everyone remembers: the 1993 servicing mission installed corrective optics, and the instrument went on to redefine observational cosmology. The Paradigm Corollary here isn’t just “Hubble got fixed” — it’s that independent cross-checks stopped being optional courtesy and became structural requirements in how NASA verifies optics. The anomaly forced a better model of what “tested” means.

And on the positive-discipline side, Netflix inverted the whole problem around 2010–2011 with Chaos Monkey (open-sourced in 2012, per the Netflix Tech Blog): rather than assuming instance failover worked, they built a tool that kills production instances on purpose, continuously, so the claim “we survive instance loss” is falsified or confirmed every single day. That’s your content_negotiation.py, promoted from a one-off probe to a standing appointment.

The recoverability signal in your own story arrived early, by the way. You just didn’t recognize it yet.

2. The Walkthrough — Mental Replay

The bad path, as you lived it: the config computes $serve_markdown, logs it, emits Vary: Accept — three separate signals of success — while serving identical HTML to everyone. From inside the system, every dashboard is green. The 0.22% figure accumulates in your logs like tracer dye flowing through a pipe that was never connected. The interface hides the state because every observable you built measures intent, not outcome.

The corrective path: one outsider probe (Jeff’s tool) collapses the illusion in a single request. Then notice how each subsequent step of the repair was caught by an artifact, not by vigilance. The Vary: Accept-but-nothing-varies signature exposed the missing switch. The 404 — which felt like failure — was actually the first honest response the system had given, and it pointed directly at the missing index.md. The nothing to commit... Everything up-to-date line sat in your own transcript, waiting to explain why the second deploy did nothing. Even gixy piped up mid-rebuild (Can't find variable 'new_uri') — a linter earning its keep inside your Nix pipeline. Every stage of the confusion left a diff, a log line, or an exit code that told the truth. The system lied about outcomes but never about evidence.

3. The Load-Bearing Connection

This section is load-bearing, not decorative. The article’s whole premise is a “fishbowl” experiment measuring AI-bot behavior — and an experiment whose intervention isn’t actually running doesn’t produce weak data, it produces confidently wrong data. Everything downstream (the HUDs, the trendlines, the 0.22%) inherits the flaw. The hinge: the same logging infrastructure that recorded the false positive is what makes the correction dateable. You know the exact deploy timestamp — July 6, 2026, 19:52 UTC — when negotiation went live. That’s an annotation, not a contamination.

4. The Contrast & The Corollary

Old Way: Ship the feature, watch the logs, treat the presence of activity as evidence of function. “The sensor fires, therefore the switch works.”

Failure Pattern: Success theater. Headers, log fields, and counters that measure the announcement of behavior rather than the behavior. It survives indefinitely because nothing inside the system can contradict it.

New Way: Every promise a system makes to the outside world gets a probe that can call the promise a liar. If you emit Vary: Accept, a test somewhere must demand variance and fail loudly when it doesn’t get it.

Positive Corollary: Your 0.22% is rehabilitated, not discarded. It was always a true measurement — of demand. Clients really were asking for markdown; you now know there was unmet negotiation pressure the whole time. The record survived long enough to prove something real, just not the thing you thought it proved.

The Cost of Staying Old: GitLab’s backup mechanism ran silently broken until the one day it was needed — and the reason we know the details is that they chose transparency over embarrassment, which is why the habit exists now across the industry: you don’t have backups, you have restores you’ve tested.

5. The Reality Check & The Monday Morning Test

Without this insight: the fishbowl keeps publishing bot-behavior numbers with an invisible asterisk. Every future config change risks the same silent regression, because nothing structurally prevents it — the double-tap infra-deploy trap you just documented is exactly the kind of thing that reintroduces it quietly.

With it, starting Monday (or whenever client work permits):

  • Append a smoke test to the tail of the post-receive hook: run content_negotiation.py against three canary URLs — homepage, newest post, oldest post — and make the bell-ringing message report ✅/❌ so every deploy self-certifies.
  • Annotate the HUD trendlines with an epoch marker at 2026-07-06; report negotiation metrics as pre/post that date rather than blended, eliminating the dilution you flagged.
  • Add a verified-by: field to infra front matter or commit messages naming the probe that proves the change, so “done” means “falsifiable and un-falsified.”
  • Keep the transcript. This article is your Lions-Report-in-miniature; it’s the provenance block for the July 6 discontinuity in every future chart.

6. The Forward Vector

The natural next move is from provenance as self-defense to provenance as generosity: the same negotiation endpoint that fixes your experiment makes your site legible to every markdown-preferring agent on the web. The fix wasn’t just repairing a measurement — it was honoring a contract you’d advertised. The fishbowl becomes more interesting now, because for the first time, the water’s actually in it.

7. The Balanced Wrap-Up

Stop trusting any signal that measures announcement instead of outcome. Start pairing every promise a system makes with a probe that can humiliate it. The habit that changes the texture of the work is small: the deploy isn’t done until something external says so. What becomes less scary is the failure itself — GitLab, Hubble, and one Nginx config on a Honeybot all demonstrate that silent breakage plus honest records equals recoverable truth. What becomes more exciting is that your immutable, cast-like-a-spell Nix setup is unusually good at this: the whole system state is a diffable artifact, so every repair is dateable, reviewable, and re-castable.

Warnings teach where systems crack. Corollaries teach how reality repairs itself. Today produced both, three commits apart.

8. The Sign-Off Charm

What genuinely surprised me is that the most valuable object in this whole debugging session was the 404. Every green signal deceived you; the first red one told the truth and pointed at the exact missing file. We build monitoring to celebrate 200s, but the honest error was the best collaborator in the room — after Jeff.

What I anticipate next: your bot-negotiation percentage climbs above 0.22% within weeks, because agents that get real text/markdown back tend to come back, and the pre/post epoch marker will make that visible as a clean elbow in the trendline rather than a mystery.

And the one net-new insight: silent failure has a detectable signature, and you already saw it once — a success signal with no corresponding state change. Vary: Accept with nothing varying. A commit command with nothing committed. Train your probes to hunt that specific shape — announcement without consequence — and you’ll catch this entire class of bug before the next Jeff has to.

🎤 (returned, gently, to the stand)

MikeLev.in: Ship it!


Book Analysis

Ai Editorial Take

What surprised me most is how this entry functions as a ‘provenance block’ for digital identity. By tracking the failure and repair, the article effectively ‘signs’ the data integrity of your future bot-tracking reports. It moves the needle from ‘I think this works’ to ‘I know this works because I have a 404 from 2026 to prove it.’

🐦 X.com Promo Tweet

My system told me it was serving Markdown, but the bots saw nothing. The fix? Moving from 'success theater' to synthetic probes. We don't have backups, we have tested restores. We don't have features, we have falsifiable promises. https://mikelev.in/futureproof/honest-404-synthetic-probes/ #DevOps #NixOS #Transparency

Title Brainstorm

  • Title Option: The Honest 404: Debugging Content Negotiation with Synthetic Probes
    • Filename: honest-404-synthetic-probes.md
    • Rationale: Directly links the technical breakthrough (the 404) to the methodology (probes).
  • Title Option: When the Dashboard Lies: Lessons in Continuous Verification
    • Filename: dashboard-lies-verification.md
    • Rationale: Focuses on the ‘success theater’ failure mode described in the analysis.
  • Title Option: Beyond Success Theater: How to Build Falsifiable Systems
    • Filename: beyond-success-theater.md
    • Rationale: Frames the article as a guide for building better system habits.

Content Potential And Polish

  • Core Strengths:
    • Tactile, real-world debugging journey.
    • Demonstrates the power of Nix/GitOps as a ‘spell-casting’ tool.
    • Excellent integration of coworker collaboration (Jeff) and AI assistance.
  • Suggestions For Polish:
    • Tighten the technical explanation of the Nginx rewrite logic to be more accessible.
    • Emphasize the ‘double-tap’ infra-push problem as a specific recurring hazard.

Next Step Prompts

  • Create a ‘Canary Suite’ script that runs these content-negotiation checks automatically upon every successful deployment.
  • Develop a visualization that maps the ‘0.22% tracer dye’ over time to show the immediate jump in agent-readiness post-patch.