docs(archive): repair 270 broken archive links and add a repo-wide link-resolution gate (BACKLOG #1095) - #281
Merged
Merged
Conversation
…#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
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
disabled auto-merge
August 7, 2026 22:42
…o claude/link-resolution-gate
…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
enabled auto-merge (squash)
August 7, 2026 23:10
This was referenced Aug 8, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Authored by the session in worktree
sweet-dhawan-57cbc8; pushed and opened by the coordinator at itsrequest. 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.pyandtests/test_archive_link_resolution.py, so full CI applies and the doc-drift guards actually run ratherthan skipping under the
noncodeallowlist.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-....mdis correct indocs/BACKLOG.mdand broken the instant it lands indocs/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
8a4472094d5f6304, not stacked (main has since moved to4cc84e1e)M docs/archive/backlog/BACKLOG-CLOSED.md,A scripts/docs/link_check.py,A tests/test_archive_link_resolution.pydocs/BACKLOG.mduntouched -- no contention with #1096/#278sweet-dhawan-57cbc8on this branchI 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
8a447209in a detached worktree:docs/archive/backlog/BACKLOG-CLOSED.md) ->test_archive_links_all_resolveFAILED. The gate can see its own subject.../../security/NOPE-NOT-REAL.md) -> passes.That is the intended exemption, not a fail-open:
docs/security,reviewsandmarketingare adeliberate 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.mdmatched nothing and the exemptionwould 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 (
>=1file,>=200links) 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_SUBTREEoncethe 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/checkandmypy --strictclean;banditpasses with therepo's standard
nosec B603 B607on three fixed-argvgitcalls; the 7 doc-drift guards run by hand(99 passed);
backlog_status_check.pyOK 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: trueonly one PR can be up-to-date at a time. It is queued behind the trains andbehind 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
8a447209and 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.pytotests/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.mdIS 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:
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 inWITHHELDwas 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, becausegit ls-fileslists 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:
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 inWITHHELDas a deliberate publishing boundary alongsidedocs/security,docs/reviews,docs/marketinganddocs/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.ymlgatespytestoncode == '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 tomainafter 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) andtest_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.