Skip to content

docs(archive): repair 270 broken archive links and add a repo-wide link-resolution gate (BACKLOG #1095) - #281

Merged
wshallwshall merged 9 commits into
mainfrom
claude/link-resolution-gate
Aug 8, 2026
Merged

docs(archive): repair 270 broken archive links and add a repo-wide link-resolution gate (BACKLOG #1095)#281
wshallwshall merged 9 commits into
mainfrom
claude/link-resolution-gate

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Authored by the session in worktree sweet-dhawan-57cbc8; pushed and opened by the coordinator at its
request. BACKLOG #1095, claimed to that worktree (claim.ps1 -Take 1095, verified in the registry).

This one is code-touching, unlike #279 and #280 -- it adds scripts/docs/link_check.py and
tests/test_archive_link_resolution.py, so full CI applies and the doc-drift guards actually run rather
than skipping under the noncode allowlist.

The root cause, measured rather than inferred

Archiving moves an item's text verbatim two directories deeper and nothing rewrites its relative links.
So adr/0083-....md is correct in docs/BACKLOG.md and broken the instant it lands in
docs/archive/backlog/.

The distribution is the proof: 267 of the archive's 270 broken links resolve cleanly when read from
docs/
. That is the signature of the move, not of authoring. The remaining 3 are ADR slug rot
(0080/0144/0065 merged under different titles than cited; none ever existed under the cited name). All 270
repaired.

There is no archival tooling -- the author looked before writing a guard. The move is manual, which
means #1094's line about "the archival pass generates the anchor" describes a generator that was never
built, and a guard is the only thing that can catch the next occurrence.

Coordinator verification

claim verified
tip 8a447209 yes
base 4d5f6304, not stacked (main has since moved to 4cc84e1e) yes
3 files, +485/-208 yes -- M docs/archive/backlog/BACKLOG-CLOSED.md, A scripts/docs/link_check.py, A tests/test_archive_link_resolution.py
docs/BACKLOG.md untouched -- no contention with #1096/#278 yes -- 0 changed
disjoint from #279 and #280 yes -- 0 overlapping files each
#1095 claimed to the authoring worktree yes -- registry shows it held by sweet-dhawan-57cbc8 on this branch

I made the gate fail on purpose, twice, rather than trusting its green

This repo's standing rule is that a green gate is evidence only once it has been shown it can go red, with
the mutation landing outside any exemption. Run against 8a447209 in a detached worktree:

  1. Planted a plainly broken link inside the gate's scope (docs/archive/backlog/BACKLOG-CLOSED.md) ->
    test_archive_links_all_resolve FAILED. The gate can see its own subject.
  2. Planted a broken link disguised as a withheld path (../../security/NOPE-NOT-REAL.md) -> passes.
    That is the intended exemption, not a fail-open: docs/security, reviews and marketing are a
    deliberate publishing boundary, so a link there cannot be distinguished from a withheld-but-real target.

Worth recording because the author caught a real fail-open here before commit: the first version compared
the raw href against the withheld prefixes, so ../../security/X.md matched nothing and the exemption
would have failed open the moment the gate widened.

The shipped test also plants its own failure -- it builds a throwaway repo with one broken and one good
link and asserts the checker reports exactly the broken one -- plus an anti-narrowing floor (>=1 file,
>=200 links) so it cannot pass on an empty scan.

Why the gate's scope is deliberately narrow

Repo-wide the checker still reports 341 unresolved, because #279 and #280 are unmerged. A gate that
is red on arrival gets suppressed, not fixed
-- the same reasoning the ledger gate records -- so the test
asserts only docs/archive/backlog, which is now clean. Widening is a one-line change to _SUBTREE once
the queue drains; the author deliberately did not take it.

Author's verification, as reported

Archive 270 -> 0 broken across 277 links; a known-missing path pushed through the checker afterwards to
prove it still reports a miss; ruff format/check and mypy --strict clean; bandit passes with the
repo's standard nosec B603 B607 on three fixed-argv git calls; the 7 doc-drift guards run by hand
(99 passed); backlog_status_check.py OK at 365 items.

Merge ordering

Auto-merge deliberately NOT armed. This is a code PR and costs a full CI cycle, and #274 (seven PRs) is
in flight; under strict: true only one PR can be up-to-date at a time. It is queued behind the trains and
behind the two docs PRs, and will be armed when a slot is free.


AMENDMENT, added by the coordinator before merge. Everything above describes an earlier state of this branch and is now partly false.

Kept rather than rewritten, because the verification recorded above was true of 8a447209 and the reasoning is worth preserving. But the branch has moved, and the body would otherwise have carried a false description into the squash commit.

What actually changed

The gate went repo-wide. It was _SUBTREE = "docs/archive/backlog". It is now _GATE_SUBTREE = None, checking every tracked markdown file in the repo. That was the point of the remaining #1095 work, and it is the substantive change since the text above was written.

The test file was renamed tests/test_archive_link_resolution.py to tests/test_link_resolution.py. The old name was true at archive scope and became a false claim once the assertion went repo-wide. No references to the old name remain.

docs/BACKLOG.md IS now touched. The table above says it is not, and that claim was true when written. It contends with any other open PR appending to the same file tail.

Diff is now 6 files, +650/-216: .github/workflows/ci.yml, docs/BACKLOG.md, docs/archive/backlog/BACKLOG-CLOSED.md, docs/testing/master-test-plan/08-publishing-and-promotion.md, scripts/docs/link_check.py, tests/test_link_resolution.py.

A real defect was found in the resolver, and fixed

The original resolution condition was:

if target not in tracked and not (root / target).exists():

Tracked or present on disk. So a link to a present-but-untracked file resolved in a long-lived local checkout and would fail in CI, which clones clean. The verdict depended on which checkout it ran in. The .claude/ entry in WITHHELD was not defending against that class; it was covering the one instance that had been enumerated - a compensating control resting on a false premise, which is the defect SDS-3.7 names.

Fixed structurally: tracked_paths() returns every tracked file plus every ancestor prefix of one, and resolution consults that set alone. No filesystem fallback. The naive fix - simply deleting the fallback - was measured first and would have failed 122 legitimate directory links, because git ls-files lists files and never directories.

Demonstrated by A/B on one fixture, not argued. A markdown file linking to (a) a present-but-untracked file, (b) a directory containing a tracked file, (c) a genuinely absent file:

resolver result
before the fix 1 unresolved - misses the present-but-untracked target
after the fix 2 unresolved - catches it; the directory link still resolves

Run independently by the coordinator against its own fixture, not against the branch's tests.

The property this gate now has, stated plainly

It checks all 5,327 links on every run, not only links in changed files. So the first broken link anywhere in the repo - an ADR retitled, a doc moved, an archive sweep - reds every open PR until it is repaired, not only the PR that caused it.

That is simultaneously the value (it cannot rot silently) and the cost (it couples the queue). Green at 5,327 of 5,327 across 347 files at time of merge, so it lands clean. Approved by the owner as built, with that consequence stated in advance.

Environment-independence is structural, not enumerated: tracked-set-only resolution with derived directory prefixes. .claude/ remains in WITHHELD as a deliberate publishing boundary alongside docs/security, docs/reviews, docs/marketing and docs/releases - retained as policy, not as protection, since the resolver no longer needs it to stay environment-independent. Owner ruled on that specifically.

Where it runs

ci.yml gates pytest on code == 'true' || event_name == 'push', so on a docs-only PR - exactly the PRs most likely to break a relative link - the repo-wide assertion would not have run at all, and would have fired on the push to main after the merge. The gate is therefore also registered in the ungated doc-guard list, in both the printed manifest and the pytest invocation, so "print what you scanned" survives. Verified with that step's exact command line.

Tests: 11 collected from 7 functions, all passing. Two of them pin the resolver pair directly - test_resolution_ignores_the_filesystem (present-on-disk but untracked must FAIL) and test_directory_links_resolve (a directory containing a tracked file must PASS).

Provenance

All commits authored by the session in worktree sweet-dhawan-57cbc8, unmodified. Pushed by the coordinator session under an explicit owner grant covering external repo actions. Design approved by the owner as built.

…#1095)

ROOT CAUSE, measured rather than inferred. Closing a backlog item moves its text
VERBATIM from docs/BACKLOG.md into docs/archive/backlog/BACKLOG-CLOSED.md -- two
directories deeper -- and nothing rewrites its relative links. A link written
"adr/0083-x.md" was correct while the item lived in docs/ and resolves to
docs/archive/backlog/adr/0083-x.md the moment it lands.

The proof is the distribution, not the story: 267 of the archive's 270 broken
links resolve CLEANLY when read from docs/. That is what pins the cause to the
move rather than to authoring error. The other 3 are ADR slug rot -- the ADR
merged under a different title than the one cited:
    0080-tls-syslog-forwarding.md            -> 0080-offbox-forwarding-tls-defaults.md
    0144-handler-config-taint-rules.md       -> 0144-security-lint-gate-over-admin-authored-router-handler-config.md
    0065-mount-the-web-console-in-process.md -> 0065-web-ops-dashboard.md
None was ever committed under the cited name, so those citations were written
from working titles that changed before the ADR merged. Repointed by ADR NUMBER,
and only where exactly one docs/adr/NNNN-* exists.

All 270 repaired. 208 insertions / 208 deletions in the archive, line-for-line.

THERE IS NOTHING TO FIX UPSTREAM. I looked for the archival tooling before
writing a guard: none exists. The move is manual, so #1094's remark that "the
archival pass generates the anchor" describes a generator that was never built.
A guard is therefore the only thing that can catch the next one.

THE GUARD
  scripts/docs/link_check.py -- resolves every relative markdown link. Repo-wide
  capable, takes an optional subtree. Prints WHAT IT SCANNED next to the verdict,
  because a green run over three files reads identically to a green run over the
  repo.

  Deliberately does NOT check: absolute URLs (reachability is a network question),
  fragments (heading slugs churn on every retitle), the withheld directories
  docs/security|reviews|marketing (gitignored post-cutover -- the master test plan
  states a missing path there is a publishing boundary, not a defect), fenced code
  (a path inside a fence is sample output), and file.py:27 citation targets (they
  cannot resolve whatever prefix is used; repairing them is a convention decision).
  A checker that overreaches is one people delete.

  tests/test_archive_link_resolution.py asserts the archive is clean, and:
    - refuses to pass on an empty scan (>= 1 file, >= 200 links), the same
      anti-narrowing floor backlog_status_check.py uses. Without it the test
      passes just as happily when a path change makes it read nothing.
    - PLANTS A BROKEN LINK in a throwaway repo and asserts the checker reports
      exactly it. A green result is evidence only if the checker can see the class.
    - pins the withheld-directory exemption.

SCOPE IS THE ARCHIVE ONLY, deliberately. Repo-wide the checker still reports 341
unresolved, because PRs #279 and #280 are not merged yet. A gate that is red on
arrival gets suppressed rather than fixed -- the same reasoning the ledger gate
records -- so this asserts only the subtree that is clean. Widening it is a
one-line change to _SUBTREE once the rest lands.

A BUG MY OWN TEST CAUGHT, recorded because it would have shipped silently: the
first version compared the RAW HREF against the withheld prefixes. A relative
link "../../security/X.md" shares no prefix with the repo-root form
"docs/security/", so the exemption matched nothing and would have flagged every
withheld link the moment the gate widened. Only the planted-case test found it.

VERIFICATION
  - Broken relative links in the archive: 270 -> 0, over 277 links checked.
  - A known-missing path run through the same checker afterwards to prove it
    still reports a miss.
  - ruff format, ruff check, mypy strict clean; bandit passes with the repo's
    standard nosec B603 B607 form on the three fixed-argv git calls.
  - The 7 doc-drift guards run by hand (99 passed) -- they are SKIPPED on a
    docs-only PR because .md is in the noncode allowlist and fire on the push to
    main afterwards, so a green PR check is not evidence there.
  - backlog_status_check.py: OK, 365 items, unchanged.
  - Cut from origin/main. Touches no file in PR #279 or #280, and neither of
    those touched the archive, so there is no conflict in either direction.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 7, 2026 22:03
…e positives

The gate was archive-only because a repo-wide assertion was red over pre-existing
breakage, and a gate red on arrival gets suppressed rather than fixed. This makes
it repo-wide. Three of the eight failures standing in the way were not defects.

Inline code. The checker already skips fenced blocks on the grounds that a path
inside them is being shown, not offered. A backtick span is the same argument at
smaller scale, and four sites turn on it: a regex whose character class contains
"](", two VS Code command: URIs -- one quoted in ADR 0110 as an attack payload --
and ADR 0160 quoting the very link it records as removed. Repointing any of them
would corrupt the text. Tested by POSITION, not shape, so the dominant idiom
[`x.md`](../x.md) keeps being checked; the test asserts both halves, because a
shape rule would stop checking most of the docs while staying green. Cost,
measured: 10 links of 5,344 no longer checked, five of them previously passing.

docs/releases/ is gitignored by ADR 0160 Phase 1, like docs/security/ and its
siblings. One archived throughput doc still cites the v0.1 plan that moved out.

.claude/ is the one to remember. It is gitignored but PRESENT in a long-lived
local checkout, so a repo-wide run passes there and fails on CI's clean clone. The
first measurement of this class was taken in such a checkout and undercounted it
by exactly 7 links; widening on that number would have put the gate red on arrival
-- the outcome the first paragraph exists to avoid. Read a gate result as a fact
about the configuration it ran in.

The exemption list is pinned as a set, so adding a prefix is a deliberate act with
a failing test attached rather than a silent widening of what goes unchecked.
Verified by planting a break in a real doc outside the archive and watching the
widened gate fail, then restoring: 5,327 links across 347 files, zero unresolved.
The three genuine breaks left repo-wide once the false positives were removed:

ADR slug rot in docs/BACKLOG.md:3073. Two ADRs merged under different titles than
the ones cited -- 0094 as granular-expiry-only-tls-relaxation, 0118 as
secure-by-default-security-configuration-section. The number is the identity, so
these repoint with confidence; the same class as the three the archive pass found.

One survivor of the 333-href repair (#280) in the same subtree:
08-publishing-and-promotion.md cited ../VERSION-CONTROL.md where the file is two
levels up, not one.

Four BACKLOG.md:<line> anchors in the archive become item references. They are
here rather than on the docs-only branch because this PR already edits this file
within three lines of them, and adjacent hunks conflict. Root cause of the whole
anchor class: commit 4ea1501 (the master test plan) is NOT a descendant of
03f1fbd (the 185-item archival) -- parallel branches, so anchors written against
an 8,742-line ledger landed beside a commit that cut it to 3,858. Each of the four
was recovered from its citing prose and adversarially re-verified against the item
it names; the line numbers were dead, the item numbers survive archival, which is
the point.
@wshallwshall
wshallwshall disabled auto-merge August 7, 2026 22:42
…y break

Widening the gate to the repo left a hole I put there. `pytest` is gated on
`code == 'true'`, so on a docs-only PR the new repo-wide assertion did not run at
all -- it would have fired on the push to main afterwards, after the merge. The
PRs most likely to break a relative markdown link are exactly the ones that were
skipping the check.

ci.yml already documents this class and keeps an ungated list for it, citing four
docs-only PRs that merged on 2026-08-04 with citation errors no guard caught --
one of them about inbound citations of `docs/releases/`, which is the same
directory this gate now treats as withheld. The gate belongs on that list; it is
added to both the printed manifest and the pytest invocation, so the "print what
you scanned" property is preserved.

Verified by running the step's exact command line: 184 passed, 89 skipped.

Also renames test_archive_link_resolution.py -> test_link_resolution.py. The old
name was accurate when the scope was `docs/archive/backlog` and became a false
claim the moment the assertion went repo-wide -- a file whose name understates
what it enforces is the same defect as a commit message that understates its own
change. Nothing outside the file referenced it by name.
…system

The gate now gives the same answer in a long-lived local checkout and on CI's
clean clone BY CONSTRUCTION, rather than by enumerating the paths where the two
differ. For a repo-wide invariant that is the difference between a control and a
coin flip, and it is the property the PR body was about to claim on the strength
of an exemption list that did not provide it.

The old resolver accepted `target in tracked OR (root/target).exists()`. That
second arm passes anything merely PRESENT, and gitignored-but-present paths are
exactly the set a developer has and CI does not. It is the mechanism of the
.claude/ finding, not a defence against it: 7 docs link to .claude/settings.json,
which passed here and would have failed on the runner, and the first repo-wide
measurement undercounted by exactly those 7.

Dropping the fallback outright would have been wrong, and measuring said so before
I proposed it: `git ls-files` lists files and NEVER directories, so 122 legitimate
directory links (docs/adr, environments, .github/workflows) resolve only via the
filesystem arm. So tracked_paths() derives every ancestor prefix of every tracked
file and resolves against files plus derived directories. Repo-wide result is
unchanged at 5,327 links, zero unresolved.

Two tests pin the pair, and the first is demonstrated rather than argued: a file
PRESENT ON DISK but untracked must fail (verified by re-running the old resolver
against the same planted repo -- it reports 0 failures where the new one reports
1), and a directory containing a tracked file must resolve.

.claude/ stays in WITHHELD as POLICY, not as protection, and its comment now says
so. It was added as protection; keeping that framing after the structural fix
would be a compensating control resting on a premise that is no longer why it
holds (SDS-3.7). Removing the entry would now make those 7 links fail honestly and
identically in every checkout.
@wshallwshall wshallwshall changed the title docs(archive): repair 270 broken archive links and add a link-resolution gate (BACKLOG #1095) docs(archive): repair 270 broken archive links and add a repo-wide link-resolution gate (BACKLOG #1095) Aug 7, 2026
@wshallwshall
wshallwshall merged commit 6cb34f5 into main Aug 8, 2026
51 of 52 checks passed
@wshallwshall
wshallwshall deleted the claude/link-resolution-gate branch August 8, 2026 00:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant