Plan: Edge Semantics (provenance + typed rel- edges) - IMPLEMENTED (branch edge-semantics, 2026-07-13)
Fills the "Edge semantics: give edges more information" roadmap gap from the 2026-07-13 knowledge-layer analysis. Implemented in one changeset on branch edge-semantics; awaiting commit + PR.
What shipped
Two halves, zero server/protocol/store changes:
- Edge provenance: every crawled edge carries the link's label text, the source section anchor it appears under, and an occurrence count. Duplicate links between the same pair aggregate into one counted edge; first non-empty label wins.
- Typed relations: publisher-metadata convention
rel-<predicate>(hyphen, never colon; metadata keys allow only[a-z0-9-]), CSV values resolved against the doc URL, ingested by both crawlers as typed edges. Malformed refs, self-refs, and empty predicates are skipped silently.
Design decisions
- Edge identity is {From, To, Rel}; plain body links have Rel "". A plain link and a typed relation between the same pair are two distinct edges.
- Store Merge is last-crawl-wins for Label/Anchor/Count (mirrors node upserts): idempotent, counts never inflate.
- graph.json schema stays v1 (new StoredEdge fields are omitempty; legacy files load with Count normalized to 1).
- /graph.md Edges table widened to
| From | To | Rel | Label | Anchor | Count |; ParseExport accepts both this and the legacy 2-column row. - One shared annotation renderer
graph.EdgeAnnotation([supersedes],("Getting started", #intro, x3)) used by client MCP, broker MCP, and both backlink lists; pinned-literal tests on both sides guard drift. - Fetch callback widened to a struct returning
(graph.FetchResult, error)with a Metadata field; this also paid the /debt.md "Crawl fetcher callback returns four positional values" item and deleted the caller-lessgraph.ClientFetcheradapter. - Shared enriched extraction lives in
client/mdoutline.AnchoredLinks(mdoutline already imports links, so links could not host it without a cycle).
Touched surfaces
client/mdoutline (new AnchoredLinks), client/graph (Edge fields, AddEdgeInfo aggregation, EdgeAnnotation, RelEdges parser, crawl enrichment), client/graphstore (StoredEdge, Merge, BacklinkEntry enrichment, dual-format export/parse), client/internal/fedcrawl (recordEdges via AnchoredLinks + RelEdges with existing loopback/normalize filters), demarkus-mcp (formatGraph, backlinks, explore, tool descriptions), broker (same surfaces + MCP-API.md), docs (ADR 0004, DESIGN.md, agent-cookbook).
Verification done
- Full test suites green in client and tools modules;
bash pre-commit.shclean. - Live e2e: dev server on :6399, published
adr.mdwith-meta rel-supersedes=/old.mdand a twice-linked doc under a## Contextheading; crawl produced| adr.md | design.md | | design doc | context | 2 |and| adr.md | old.md | supersedes | | | 1 |. - Legacy compat live: the real pre-change ~/.mark/graph.json (133 nodes / 190 edges) loads and exports through the new binary with Count normalized to 1.
Follow-ups
- Surface labels/rels in the TUI graph view (deliberately deferred this round).
- When publish-time edge extraction lands in the store backends, capture the same fields; conformance suite first per the backend-parity principle.
- Consider a curated rel- predicate vocabulary in the plugins (supersedes, implements, depends-on, derived-from).
See repo docs/adr/0004-edge-semantics-rel-convention.md for the full decision record.
PR 251 review round (CodeRabbit, 2026-07-13)
Four of five findings addressed, one declined:
- Fixed: RelEdges now rejects any internal whitespace via unicode.IsSpace (newlines from a hostile or broken remote server no longer pass).
- Fixed (the sharp one): Merge now replaces the stored outgoing edge set for every source the crawl actually fetched, so deleted links and dropped rel- predicates no longer linger in backlink queries. External/error sources keep their stored edges. This stale-edge behavior predated the PR but the enrichment made it worth fixing here. ADR 0004 updated.
- Fixed: empty-label links keep section provenance; LinkInfo gains BlockStart (enclosing block start, valid even when brackets are unknown; headings cannot split a block, so it is section-accurate). Bracket positions stay -1 so the TUI marker-injection contract is untouched.
- Fixed: AnchoredLinks heading attribution is now a binary search per link instead of a rescan.
- Declined: bumping graph.json schemaVersion to 2 for the enriched edges. The file is a rebuildable crawl cache, not a source of truth; an old binary reading a new file degrades gracefully (drops enrichment, output identical to pre-feature) and any loss is repaired by the next crawl, while a version bump would make every old binary hard-reject the file. The version exists for breaking shape changes, and this one is additive.
PR 251 review round 2 (CodeRabbit, 2026-07-13)
- Fixed (critical, real): the BlockStart ancestor walk called p.Lines() on inline parents, and goldmark's BaseInline.Lines() panics by contract, so any link wrapped in emphasis (
**[text](url)**) crashed extraction. Guard with p.Type() == ast.TypeBlock. Reproduced the panic against the committed code with a new test before fixing (test fails pre-fix, passes post-fix). Regression coverage added at all three levels: links (link inside bold), mdoutline (bold and italic wrapped links), crawl (bold link records label and anchor). - Declined: debug-level log for skipped malformed rel- refs. RelEdges is a pure function with no logging dependency; the skip rules are deterministic and documented in code and ADR 0004; CodeRabbit itself rated it low value.
- Duplicate schema-bump comment: prior decline stands (rebuildable cache, additive omitempty fields, hard version bump is the worse failure mode).
Lesson: when a helper walks AST ancestors, test links nested inside inline formatting; the first review's tests only covered formatting inside links, not links inside formatting.
PR 251 review round 3 (CodeRabbit, 2026-07-13)
One nitpick, accepted: the links-level extraction tests never asserted the new LinkInfo.BlockStart field. Added a case with a link in a second paragraph (non-zero BlockStart 18) plus an empty-label link in the same block (bracket positions -1, BlockStart still 18), and wired BlockStart into the per-field compare loop so every existing case now pins it too.
Merged
PR #251, merged to main 2026-07-14 as commit 8798d0a. Roadmap entry flipped to DONE; the resolved fetch-callback item removed from /debt.md. This plan is complete; follow-ups live in the roadmap (TUI display, publish-time edge extraction) and the next increment is /plans/graph-hub-seed.md.
Related documents
- ADR 0004: edge semantics rel convention: the decision record this plan implements
- Roadmap: edge semantics gap flipped to done; follow-ups live here
- Debt: fetch-callback item paid by this change
- Journal 2026-07-13: implementation and review rounds
- Journal 2026-07-14: PR #251 merge