Rechecked 2026-09-23 at 0ad26871. docs/cli.md:568 is now :572 and
docs/the-audit-chain.md:492 is now :501, corrected below everywhere they appear. All seven links still resolve at source and
still point at pages mdBook never writes.
mdbook build rewrites every relative .md link to .html. A link that points
out of docs/, and a link to a page missing from docs/SUMMARY.md, both come
out pointing at a file the build never wrote. Seven links across four pages land
on nothing. The repo runs markdown-link-check in CI, and it cannot see any of
this: it resolves the Markdown source, where all seven targets exist on disk. It
would not read these four pages either, which is #372.
Reproduced at 7d19864 with mdBook 0.4.40, the version .github/workflows/docs.yml
pins and installs:
$ mdbook --version
mdbook v0.4.40
$ mdbook build -d /tmp/sk-book
2026-09-05 10:49:20 [INFO] (mdbook::book): Book building has started
2026-09-05 10:49:20 [INFO] (mdbook::book): Running the html backend
$ grep -o 'href="[^"]*user-stories[^"]*"' /tmp/sk-book/cli.html | sort -u
href="user-stories.html"
$ ls /tmp/sk-book/user-stories.*
ls: cannot access '/tmp/sk-book/user-stories.*': No such file or directory
Walking every href in the built tree, with print.html skipped because it
duplicates the rest:
$ cd /tmp/sk-book
$ python3 - <<'PY'
import re, pathlib, urllib.parse
root = pathlib.Path('.')
for html in sorted(root.rglob('*.html')):
if html.name == 'print.html':
continue
for href in re.findall(r'href="([^"]+)"', html.read_text(errors='replace')):
if href.startswith(('http://', 'https://', '#', 'mailto:', '//')):
continue
target = href.split('#')[0].split('?')[0]
if not target.endswith('.html'):
continue
if not (html.parent / urllib.parse.unquote(target)).resolve().exists():
print(f"{html}\t{href}")
PY
cli.html ../README.html
cli.html user-stories.html
contributing/CONTRIBUTING.html ../../SECURITY.html
contributing/testing.html ubuntu-vm-testing.html
contributing/testing.html ../testing/user-stories.html
contributing/testing.html ../../HACKING.html
the-audit-chain.html ../SECURITY.html#audit-anchoring-in-the-default-deployment
I did not check these against the published site. Verifying them there needs an
outbound request this account is not allowed to make, so the evidence above is a
local build of the same tree with the same mdBook version the workflow installs.
Why it matters
docs/cli.md:572 offers the reader the user stories, which is the document that
explains what SysKnife does end to end. docs/contributing/testing.md:333 sends
a contributor to the VM recording procedure. Both are the moment somebody
decided to go deeper, and both give them a 404 on a site whose whole job is to
answer that.
All seven targets are present in the tree, which is why a source-level link
checker passes over them:
$ ls -l README.md SECURITY.md HACKING.md docs/user-stories.md docs/testing/user-stories.md docs/contributing/ubuntu-vm-testing.md 2>&1
-rw-rw-r-- 1 entropia entropia 18618 sep 4 07:01 docs/contributing/ubuntu-vm-testing.md
-rw-rw-r-- 1 entropia entropia 19617 sep 4 07:01 docs/testing/user-stories.md
-rw-rw-r-- 1 entropia entropia 15508 sep 4 07:01 docs/user-stories.md
-rw-rw-r-- 1 entropia entropia 34220 sep 4 07:01 HACKING.md
-rw-rw-r-- 1 entropia entropia 22548 sep 4 07:01 README.md
-rw-rw-r-- 1 entropia entropia 20463 sep 4 07:01 SECURITY.md
Fix the two classes differently.
Four links point outside docs/. README.md, SECURITY.md and HACKING.md
live at the repo root, so mdBook cannot include them at any depth of ../, and
the .md to .html rewrite happens anyway. One file already
does it both ways: docs/the-audit-chain.md:267 links SECURITY.md with a
relative path and breaks, and docs/the-audit-chain.md:501 links the same file
as https://github.com/lacs-project/sysknife/blob/main/SECURITY.md and works.
Three links point at documents that exist and are unlisted.
docs/user-stories.md, docs/testing/user-stories.md and
docs/contributing/ubuntu-vm-testing.md are all in the tree. mdBook renders only
what SUMMARY.md names, so it copied them across as raw Markdown and wrote no
HTML for any of them.
Scope
- Repoint the four outward links at
https://github.com/lacs-project/sysknife/blob/main/...,
copying the form already used at docs/the-audit-chain.md:501.
- Decide, per document, whether the three unlisted files belong in the book. Two
of them are named by CLAIM_FILES in scripts/check_evidence_claims.py, so the
project already treats them as load-bearing. Adding them to SUMMARY.md is the
smaller change; linking them by URL is defensible for anything meant to stay
out of the published book.
- The trap:
docs/user-stories.md and docs/testing/user-stories.md are two
different documents that share a basename.
$ md5sum docs/user-stories.md docs/testing/user-stories.md
17cb3f780131c1c62baa75bc0b62ca30 docs/user-stories.md
7ff722164b0ec573ab89e09dc9a92609 docs/testing/user-stories.md
$ wc -l docs/user-stories.md docs/testing/user-stories.md
438 docs/user-stories.md
593 docs/testing/user-stories.md
docs/cli.md:572 wants one and docs/contributing/testing.md:388 wants the
other. Read both before you pick a target for either link.
Tests first
A guard that builds the book and fails on any internal .html link with no file
behind it. The walk above is the whole implementation. Two things decide whether
it is worth having:
Assert the walk found links at all. A selector that matches nothing reports
zero broken links and passes, which is the failure this tracker keeps meeting;
#362 is the current example, and it cites #315 as the same defect one job over.
Count the hrefs examined and fail when that count is zero.
Prove it bites. Delete one line from docs/SUMMARY.md, rebuild, and watch the
guard name the page that lost its target. Then put it back and watch it go green.
Where it runs is a real choice. .github/workflows/docs.yml already installs
mdBook and builds the book, so a step there costs nothing and catches the break
on the commit that ships it. A tests/release/*.test.sh reaches ci-local.sh
and every contributor, and has to handle a machine with no mdBook: skip loudly,
naming the check that did not run, rather than passing quietly. #346 covers why
a silent local skip is its own defect.
Difficulty
easy. The seven fixes are one line each. The guard is a twenty-line walk over
files the docs job already produces, and deciding which of the three documents
join SUMMARY.md is the only judgement in it.
Getting started
CONTRIBUTING.md
has the build and test commands. This one needs no VM, no daemon and no LLM key:
cargo install mdbook --version 0.4.40 (or the release tarball the workflow
downloads) and mdbook build is the whole environment. No CLA and no copyright
waiver. The project is MIT.
mdbook buildrewrites every relative.mdlink to.html. A link that pointsout of
docs/, and a link to a page missing fromdocs/SUMMARY.md, both comeout pointing at a file the build never wrote. Seven links across four pages land
on nothing. The repo runs
markdown-link-checkin CI, and it cannot see any ofthis: it resolves the Markdown source, where all seven targets exist on disk. It
would not read these four pages either, which is #372.
Reproduced at
7d19864with mdBook 0.4.40, the version.github/workflows/docs.ymlpins and installs:
Walking every
hrefin the built tree, withprint.htmlskipped because itduplicates the rest:
I did not check these against the published site. Verifying them there needs an
outbound request this account is not allowed to make, so the evidence above is a
local build of the same tree with the same mdBook version the workflow installs.
Why it matters
docs/cli.md:572offers the reader the user stories, which is the document thatexplains what SysKnife does end to end.
docs/contributing/testing.md:333sendsa contributor to the VM recording procedure. Both are the moment somebody
decided to go deeper, and both give them a 404 on a site whose whole job is to
answer that.
All seven targets are present in the tree, which is why a source-level link
checker passes over them:
Fix the two classes differently.
Four links point outside
docs/.README.md,SECURITY.mdandHACKING.mdlive at the repo root, so mdBook cannot include them at any depth of
../, andthe
.mdto.htmlrewrite happens anyway. One file alreadydoes it both ways:
docs/the-audit-chain.md:267linksSECURITY.mdwith arelative path and breaks, and
docs/the-audit-chain.md:501links the same fileas
https://github.com/lacs-project/sysknife/blob/main/SECURITY.mdand works.Three links point at documents that exist and are unlisted.
docs/user-stories.md,docs/testing/user-stories.mdanddocs/contributing/ubuntu-vm-testing.mdare all in the tree. mdBook renders onlywhat
SUMMARY.mdnames, so it copied them across as raw Markdown and wrote noHTML for any of them.
Scope
https://github.com/lacs-project/sysknife/blob/main/...,copying the form already used at
docs/the-audit-chain.md:501.of them are named by
CLAIM_FILESinscripts/check_evidence_claims.py, so theproject already treats them as load-bearing. Adding them to
SUMMARY.mdis thesmaller change; linking them by URL is defensible for anything meant to stay
out of the published book.
docs/user-stories.mdanddocs/testing/user-stories.mdare twodifferent documents that share a basename.
docs/cli.md:572wants one anddocs/contributing/testing.md:388wants theother. Read both before you pick a target for either link.
Tests first
A guard that builds the book and fails on any internal
.htmllink with no filebehind it. The walk above is the whole implementation. Two things decide whether
it is worth having:
Assert the walk found links at all. A selector that matches nothing reports
zero broken links and passes, which is the failure this tracker keeps meeting;
#362 is the current example, and it cites #315 as the same defect one job over.
Count the hrefs examined and fail when that count is zero.
Prove it bites. Delete one line from
docs/SUMMARY.md, rebuild, and watch theguard name the page that lost its target. Then put it back and watch it go green.
Where it runs is a real choice.
.github/workflows/docs.ymlalready installsmdBook and builds the book, so a step there costs nothing and catches the break
on the commit that ships it. A
tests/release/*.test.shreachesci-local.shand every contributor, and has to handle a machine with no mdBook: skip loudly,
naming the check that did not run, rather than passing quietly. #346 covers why
a silent local skip is its own defect.
Difficulty
easy. The seven fixes are one line each. The guard is a twenty-line walk overfiles the docs job already produces, and deciding which of the three documents
join
SUMMARY.mdis the only judgement in it.Getting started
CONTRIBUTING.md
has the build and test commands. This one needs no VM, no daemon and no LLM key:
cargo install mdbook --version 0.4.40(or the release tarball the workflowdownloads) and
mdbook buildis the whole environment. No CLA and no copyrightwaiver. The project is MIT.