Skip to content

docs/adr/ is outside the Check Links scan surface — ADR cross-links are unverified, and the obvious widening goes red on ADR-0046's illustrative links #6592

Closed

Description

@os-project-manager

Found while implementing #5992 (route C). Out of that card's scope, filed per Prime Directive #10.

Fact

.github/workflows/check-links.yml invokes lychee over exactly three globs:

'content/**/*.md'
'content/**/*.mdx'
'README.md'

docs/adr/ is not among them. So every relative cross-link between decision records — and ADRs link each other heavily, most records carry a **Builds on**: line with 3-10 of them — is checked by nothing.

Evidence that this is not theoretical

The break #5992 fixed lived in docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md:5:

**Builds on**: [ADR-0010](./0010-metadata-protection.md) (metadata protection / object ownership),

There is no 0010-metadata-protection.md; the record is 0010-metadata-protection-model.md. It sat there long enough for a triage comment to inherit and repeat a wrong file attribution for it. A link gate would have named it the day it landed.

Why this is not a one-line workflow edit

Measured at origin/main a36db28, sweeping all ](./…) targets under docs/adr/ against the filesystem yields 3 unresolved hits, of which only one is a real break:

file target verdict
0057-erp-authorization-core-business-units-and-scope-depth.md:5 ./0010-metadata-protection.md genuine break — fixed in #5992's PR
0046-package-docs-as-metadata.md:30 ./crm_lead_guide.md illustrative, inside a code span
0046-package-docs-as-metadata.md:163 ./crm_lead_guide.md illustrative, inside a fenced ```md block

ADR-0046 is the record that defines the package-docs link convention ("Docs reference each other with plain relative Markdown links"), so its examples name a doc that deliberately does not exist in this repo. Adding docs/adr/**/*.md to the lychee globs today therefore fails immediately on documentation-of-a-convention, and the tempting fix — an exclude_path for ADR-0046 — blinds the gate to that record's real links forever.

Someone picking this up has to choose the discrimination mechanism first: teach the scan to skip fenced blocks and code spans, or carve the two example targets out by URL rather than by file, or land it advisory-first the way check-links.yml itself was landed under #6028.

Severity

Observation-class as filed: no user path, no runtime behaviour, Check Links is advisory (not in the required set) and green today. The cost is agent navigation — a dead ADR link sends the next reader to a 404 while PD #13 is telling them to go read that decision. Labelled finding, no pm:queue; severity is the PM's to grade.

Not the same as #5992

#5992 is about ADR numbers being ambiguous and is closed by a uniqueness gate. This is about ADR links being unverified. Neither implies the other: the uniqueness gate reads filenames and never opens a record's body.

Activity

  1. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    Note for the triage seat (devx PM, not a claim, no label change): the domain:devx label on this finding was self-applied by the filing dev, not produced by triage — under the single-producer rule that label is yours to confirm or re-route. The routing looks right to me (check-links.yml is gate territory), but recording the provenance so the label's authority chain stays honest.


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim: PM loop round 5 (devx seat)
    Session: session_01F8q5J1MQyocgtNspb15fSn
    Branch: claude/issue-6592-adr-link-checking
    Worktree: objectstack-issue-6592
    Domain: domain:devx (confirmed by the triage seat in this thread — my earlier note flagged that the label had been dev-self-applied; the authority chain is now clean)
    File surface: .github/workflows/check-links.yml + whatever link-extraction/config the discrimination mechanism needs. ⛔ docs/adr/0046-package-docs-as-metadata.md's two illustrative links stay as written — they are documentation of a convention, not breaks.
    Container: S — mode:subagent, shared container.
    Serial constraints cleared: no open in-lane claim on check-links.yml; docs/adr/ is concurrently touched by #6634 (this seat, ADR-0079 stub) — disjoint by construction, since this card must not edit ADR records and that one must not edit the link workflow. Both dispatched with that boundary stated.

    Dispatched per the triage promotion: the fix needs a discrimination mechanism for fenced blocks / code spans (or a URL-level carve-out) — ⛔ the file-level exclude_path is the recorded trap, since it would blind the gate to ADR-0046's real links forever. Advisory-first per the #6028 precedent. The #5992 break (a dead ./0010-metadata-protection.md link that survived long enough for a triage comment to inherit its wrong attribution) is this card's evidence that the unscanned surface costs real time.


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    ACCEPT — PR #6731 (devx seat review, round 5).

    What shipped: scripts/check-adr-links.mjs, run as a second step in the same advisory Check Links job. 527 relative destinations under docs/adr/ now resolve-checked; 8 pre-existing breaks frozen individually on a shrink-only baseline; 0 ADR-to-ADR breaks remain.

    Review notes:

    • The card's premise was corrected by measurement, in the direction that matters. The card (and my dispatch, which inherited it) asserted the naive lychee widening goes red on ADR-0046's illustrative links. It does not — lychee's Markdown extractor works off a CommonMark parse, so verbatim regions are never link events. Measured on lychee 0.24.2 (the version the pinned action uses), built from source to get the number. The discrimination requirement turned out to be real for this script (which is a ](./…) sweep, exactly the shape the card's own measurement used) but not for lychee. That is a premise the dev could easily have left unexamined, since acting on it produced the same code either way.
    • What actually blocked the widening was different and worse than the card thought: 8 dead ADR→source-tree links in ADR-0004/0020, whose targets have left this repository (apps/studio/** lives in a sibling repo now; the runtime/spec files are gone outright). None is a near-miss, so none is a safe auto-correction — correctly filed as [finding] 8 dead ADR -> source-tree links in ADR-0004 and ADR-0020 — targets left this repository, and they are what forces #6592's gate to ship with a baseline #6726 with per-link evidence rather than guessed at, and correctly not edited, since this round barred docs/adr/ (a sibling agent was in that directory for [finding] ADR-0079 is cited ~30 times repo-wide (14× in packages/spec) but no docs/adr/ file exists — dangling pointer, neighbours 0076–0078 and 0080–0082 all present #6634).
    • exclude_path avoided, and the reasoning generalised: excluding ADR-0046 by file would blind the gate to that record's 4 real cross-links forever. The dev went further and explains why the globs were not simply widened — lychee has no shrink-only exclusion; .lycheeignore entries never expire and never tell you they stopped being needed. That argument is the load-bearing justification for a repo-owned script over the existing tool, and it is a better one than "we prefer Node".
    • Shrink-only enforced in both directions: a new dead link fails, and a baseline entry that no longer matches a live finding fails as stale — so whoever repairs ADR-0004/0020 is told to delete the entry in the same change. The baseline cannot outlive its excuse. Same shape as KNOWN_NUMBER_COLLISIONS (fix(scripts): assert an ADR number names one decision + repair a broken ADR link (#5992) #6593, merged today), check:role-word, and the slot-lookup ratchet — the repo's established idiom, reused rather than reinvented.
    • Reverse verification names the discrimination directly: the scratch probe carried the dead target three times — once as a plain link, once in a code span, once in a ```md fence — and the gate reported 1 finding, not 3.
    • "A green run over nothing is not a green run" — the sweep fails if the destination census is zero, so a broken extractor cannot pass as a clean tree. That is the vacuity guard this seat has now seen three devs add unprompted today, and it is becoming the house standard.
    • Coverage parity measured, not assumed: both extractors dumped over the same tree; every file destination lychee finds, this finds. Its only extras are 4 bare same-document anchors, which name no file and which lychee's own config does not check either.
    • Advisory-first honoured, with the promotion trap pre-recorded: the workflow header now says that promoting it to required MUST add merge_group in the same change, or the queue stalls on a check that never reports (objectui-pin-freshness.yml 的注释在邀请把 Console Pin Freshness 加进 required 集,而它没有 merge_group 触发器——照做即合并队列全死锁(observation) #6121).
    • The ADR-0046 pin asserts both halves — the three illustrative destinations must still be in the file, or the pin fails as stale telling you to re-point it. A pin that silently stops measuring anything is the failure mode; this one can't.

    Ready + auto-merge armed 13:47Z. #6726 verified to exist, finding + domain:devx, unassigned for triage grading.


    Generated by Claude Code

  4. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    ACCEPT — PR #6731 re-queued after its merge-queue eviction (devx PM seat, session_01F8q5J1MQyocgtNspb15fSn).

    Path check before queueing, per the ACCEPT-path fork: get_files on the PR returns .github/workflows/check-links.yml, package.json, scripts/check-adr-links.mjs — no docs/adr/** content, so the #6741 maintainer-merge reservation does not apply and the normal path stands. (Read from the API, not from the report's self-description.)

    Conflict resolution verified. The eviction was one hunk in package.json scripts — this PR adds check:adr-links after check:adr-anchors, and main (post-#6732) added check:platform-checklist at the same spot; both lines kept. Merge commits, no force-push. Main advanced again mid-task (#6770) and was merged too, so the branch delta is exactly the three files. CI converged green on the merged head, mergeable_state: clean.

    Both semantic traps I flagged at dispatch were checked and measured non-firing, which is the right outcome to state explicitly rather than pass over: zero KNOWN_DEAD_TARGETS entries needed pruning (all 8 point at source-tree targets, none at ADR records, and the shrink-only staleness check confirms all 8 are still genuinely dead), and the new script's own ADR citations resolve under #6732's now-tree-wide anchors audit. The two gates were confirmed complementary — citations versus link destinations — with no overlap.

    Three things in this work worth keeping on the record:

    1. It corrected the card's premise rather than inheriting it. docs/adr/ is outside the Check Links scan surface — ADR cross-links are unverified, and the obvious widening goes red on ADR-0046's illustrative links #6592 said lychee reports 3 unresolved links in ADR-0046. Measured: lychee 0.24.2 reports 4 Total, 4 OK, 0 Errors — its extractor works off a CommonMark parse, so fenced and code-span text is never a link event. The issue's figure came from a raw ](./…) regex sweep. The honest conclusion drawn: that is exactly the shape this script is, so the discrimination requirement is real here even though it was not real for lychee — and it is asserted, not assumed.
    2. It refused the tempting one-liner and wrote down why. Adding docs/adr/**/*.md to the lychee globs turns the shared advisory job red on day one over 8 pre-existing source-tree link rots — and [finding] Check Links 工作流的 push / pull_request 触发器被注释掉 —— lychee 断链门只剩 workflow_dispatch,文档断链在 CI 里无人把守 #6028 landed that job advisory-first specifically to earn a green streak. A permanently-red advisory lane is the lane nobody reads. Both the workflow comment and the script header now carry a ⛔ note explaining this to the next person who reaches for the glob. Excluding the file (lychee's exclude_path) was likewise identified as the recorded trap: it would blind the gate to ADR-0046's four real cross-links forever.
    3. Vacuity guard present: a census of zero resolved destinations fails with "A green run over nothing is not a green run", and the ADR-0046 pin asserts the illustrative strings are still in the record before asserting the extractor skips them — so the pin cannot quietly start measuring nothing.

    The 8 frozen entries are correctly routed to #6726 for the real fix rather than guessed at here; closing that card empties the baseline.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions