ADR-055: A link target is checked from where the prose renders
Status Active · Filed 2026-08-16 · Issue #100
Context
Record prose is rendered into views in other directories. A journal entry lives
in record/reading.d/2026/08/16/ and is assembled into
docs/reading/2026-08-16.md; a document-rendered scheme’s source is collected
into one page; a stub’s prose lands in the index it introduces. So a relative
link target has to resolve from where the text lands, and the depth counted
from the file’s own directory is a different number.
The rule follows, and has been in CLAUDE.md since early on: never hand-write
a target, write the bare code and let luria link --fix spell it, because only
the fixer knows the frame. The affordance works. What was missing was anyone
checking the targets the fixer did not write.
An adopting project ran into it at full scale. Reading-journal entries carried hand-written targets from the first commit — the arithmetic feels checkable, which is exactly the trap — and 99 of them resolved to nowhere across eleven commits, every one of which linted clean (#100).
Nothing was going to catch it. The reference checks are all about the code:
unresolved-codes asks whether ADR-035 names a document, retired-citations
asks what its status is, legacy-spellings asks how it is spelled. A dead path
wrapped around a live code passes every one.
Firing the check on this repo’s own scaffold showed the same defect shipped:
template/record/decisions.d/README.stub links to [_template.md](_template.md)
and [design-principles.md](../design-principles.md), and the stub renders at
docs/decisions/README.md where neither exists. This repo’s own copy of that
stub had been repaired by luria link --fix at some point, which is what hid
the divergence. Same shape as the scaffolded fixture codes fixed in #96, where a fresh
luria init reported five unresolved ones: the scaffold is prose nobody
re-reads, so a defect in it survives indefinitely.
Decision
A broken-targets status class. Every relative markdown link target in a
scanned document is resolved against cfg.link_base(path) — the same authority
luria link --fix uses to write a target — and reported when it does not
exist. target-ok: acknowledges the deliberate ones.
The parts that are load-bearing rather than incidental:
link_base, not path.parent. This is the whole decision. The two frames
disagree by five directories for a journal entry, and the one a reader follows
is the view. Resolving from the source directory would accept exactly the
targets that are broken and reject the ones that work.
Resolution is textual (os.path.normpath), not Path.exists() on the raw
join. A view directory need not exist yet — luria index creates it — and
.. traversal through a missing directory fails on the filesystem while
resolving fine for a reader. Text is also what a markdown renderer does.
A report, not a lint error. ADR-035’s test is whether the violation is
always wrong and mechanically fixable. This one is always wrong and is not
fixable: the fixer owns codes, and an arbitrary path is a typo only its author
can resolve. Nameable in fail_on for a project that has cleaned up.
Targets carrying regex or format metacharacters are patterns, not paths.
uid = "(\d{4})[.:](\d{4,5})" in a config example is link-shaped by accident,
and this repo’s own record contains two such lines.
Alternatives considered
- Extend
luria link --fixto repair paths instead of checking them. It cannot: the fixer knows how to spell a target for a code, and a broken path that names no code carries no information about what was meant. Repairing only the code-shaped ones would leave the rest silently dead while looking like a complete sweep — the failure mode DP-1 objects to. - Detect indented code blocks rather than screening for metacharacters. The correct discriminator, and markdown makes it genuinely ambiguous: four spaces inside a list item is a continuation, not code. A wrong answer there silences real prose. The metacharacter screen is cruder, states its own limit, and cost one line.
- Check the
#anchortoo. A different question, an order of magnitude noisier, and heading text moves for reasons that have nothing to do with the link. Dropping the fragment and checking the file it hangs off keeps the class at one meaning. Appending an anchor deliberately does not silence a missing file — that is a test. - Status quo. The rule stays documented and unenforced, which is the state this record objects to everywhere else. The measured cost is 99 dead links in one downstream project and two in the shipped scaffold.
Consequences
An adopting project turning this on for the first time should expect findings proportional to how much prose it has hand-written, which is why the class is a warning by default rather than a failure.
target-ok: joins the acknowledgement vocabulary and is the first directive
whose argument is a path rather than a code. The parser did not need
changing, and directives.problems reports one that no longer covers a link,
so the annotation still reports itself the day it stops applying.
The check knows only that a target is not anything, never what it should have been. Someone who hand-writes a target that happens to resolve to the wrong existing file gets no finding, and that residual hazard is still carried by the ground rule alone.