# Linking

> Status: built. Extraction and resolution live in `internal/linkgraph`; decisions in `internal/notify`.

Documents reference each other with ordinary inline markdown links. Those references form a graph,
and the graph is what powers share suggestions, backlinks, and the "a document you link to just
moved" flow.

## Format

Plain markdown. Nothing custom:

```markdown
See the [onboarding guide](../onboarding.md) before your first day.
The [deploy runbook](/ops/deploy.md) covers rollback.
Extracted from [the IT handbook](../../raw/2026/09/it-handbook.md).
```

Chosen over `[[wikilinks]]` and `codna://` URIs because it is the only format that makes an export
genuinely useful: the exported tree opens in Obsidian, browses on GitHub, and renders correctly in
any markdown viewer, with every link working. See [adr/0006](https://codna.me/docs/adr/0006-inline-markdown-links.md).

### What counts as an internal link

| Href | Treated as |
|---|---|
| `../onboarding.md` | internal, relative to the source document's folder |
| `./setup.md`, `setup.md` | internal, same folder |
| `/ops/deploy.md` | internal, relative to the export root — the space is the first segment |
| `../onboarding.md#vpn` | internal; the fragment is preserved in text, ignored for resolution |
| `https://example.com` | external, ignored |
| `mailto:`, `tel:`, any scheme | external, ignored |
| `![alt](diagram.png)` | an image, not a knowledge reference — ignored |

The `.md` suffix is required. Extensionless hrefs are ambiguous with external site paths, and
demanding the extension is what keeps the exported tree honest.

### Addresses

A document's address is `space:path/slug`, which maps to a file at `<space>/<path>/<slug>.md`:

```
wiki:engineering/onboarding/laptop-setup   →   wiki/engineering/onboarding/laptop-setup.md
raw:2026/09/it-handbook                    →   raw/2026/09/it-handbook.md
```

So from `wiki/engineering/onboarding/laptop-setup.md`, the link `../onboarding.md` resolves to
`wiki:engineering/onboarding` — exactly as it would on a filesystem.

## Extraction

Runs as an outbox consumer on every content-bearing save.

1. Parse the body with **goldmark** to an AST. Not a regex — a regex over prose cannot tell a link
   in a paragraph from one inside a fenced code block, and code blocks in a knowledge base are full
   of things that look like links.
2. Walk `ast.Link` nodes. Skip images and autolinks.
3. Discard external hrefs by the table above.
4. Normalize: strip `#fragment` and `?query`, resolve `.`/`..` against the source's folder, require
   a `.md` suffix, require the first segment to name a known space.
5. Collapse duplicates into `occurrence_count`, keeping the first `link_text`.
6. Diff against the existing edges for this source, keyed on `href_as_written`. Insert what is new,
   delete what is gone, update counts.

Extraction is idempotent — running it twice on the same body produces the same edges — because
outbox delivery is at-least-once.

## Resolution

Each extracted href is resolved to a target, which determines the edge's state:

```
         ┌─ live document at that address?             ──▶ resolved
         │
         ├─ address in document_path_history?           ──▶ stale_text  (target moved; href is out of date)
href ────┤
         ├─ deleted document the link was there to see? ──▶ broken
         │
         └─ nothing                                     ──▶ pending    (may resolve later)
```

`pending` is the interesting one. Writing a link to a page that does not exist yet is normal — it is
how people plan. When a document is later created at that address, the resolver finds the pending
edges pointing there and resolves them, and the new document's backlinks light up without anyone
doing anything.

`broken` is something a link becomes, not something it is written as: a link whose target was
deleted while the link was there. A link written to the address of a page already in the trash is
`pending` like any other, and attaches itself if the page is restored or another appears there.

Resolution reads the present, though, and the present can be later than the link. So two links are
broken toward a deleted page rather than pending:

- **One whose edge already reached it.** Every edit to a page reads all its links again. A link that
  broke stays broken through its page's next edit, and its author is still asked about it.
- **One written before the deletion and read after it.** The outbox reads a new link moments after
  it is written: under a second normally, longer while a big import drains. If the target is deleted
  in between, there is no edge yet for the deletion to break. Instead the resolver finds the page
  deleted from that address after the link was written, and the link breaks as it is read. It raises
  `link.became_broken` just as the deletion would have.

So a link's author is asked about a deleted target whichever the outbox reaches first, the link or
the deletion.

## The four states

| state | `target_id` | meaning | surfaced as |
|---|---|---|---|
| `resolved` | set | target exists; href matches its current address | nothing |
| `stale_text` | set | target moved or was renamed; the href text is now wrong | "update this link, keep it as written, or drop it?" |
| `pending` | null | href points at an address with no document, or one deleted before the link was written | nothing yet |
| `broken` | set | target is tombstoned | "repoint, drop, or restore?" |

```
                    target created at that address
   pending ──────────────────────────────────────────▶ resolved
      ▲                                                 │   ▲
      │ target purged                     target moved  │   │ href edited to match
      │                                    or renamed   ▼   │      (or target moved back)
   broken ◀──────────── target deleted ──────────── stale_text
      │                                                 │
      └───────────── target restored ───────────────────┘
```

The edge carries `target_id`, so **a rename never breaks the graph**. Only the written text falls
out of date. That distinction is the whole design: staleness is a fact to surface, not a failure to
repair automatically.

## Rename and move

When `runbooks/deploy.md` becomes `ops/deploy.md`:

```
documents              path/slug updated, its own relative links rebased, version bumped
document_revisions   + 1   change_kind='move'
document_path_history + 1  the vacated address, with valid_to=now()
events               + 1   document.moved
   └─ resolver: inbound edges → stale_text  (target_id untouched)
   └─ notifier: one notification per linking document's author,
                requires_decision=true, with from/to in the payload
```

**No linking document is edited.** Rewriting every body that mentions a renamed document would
manufacture a revision on each one, attribute it to whoever did the rename, and bury the real
history under mechanical churn — in a system whose entire point is a readable, attributable history.

Each author decides:

| Choice | Effect |
|---|---|
| **Update** | The source body is edited to the new href. A normal revision, authored by that person. Edge returns to `resolved`. |
| **Keep** | Nothing in the body changes. Edge stays `stale_text`, and `kept_at` records the answer, so the question stops being asked. It still resolves via `target_id`, so nothing is lost; the export just contains a path that no longer matches. The answer lapses when the edge's state changes again (the target is deleted, or the link is rewritten), and then it is a new question. |
| **Drop** | The link markup is removed and its words kept. A normal revision. |

Keeping is `POST /api/links/{id}/keep` or the `keep_link` MCP tool; until it
existed, leaving a link as written was a fair answer with nowhere to record it,
and the same question was asked on every visit.

Links written inside **raw** documents are never a decision. Raw is never
edited, so neither updating nor dropping such a link could ever be done; its
author is not notified when the target moves or is deleted, and it is not
listed. The edge itself is tracked like any other, so backlinks still show it.

The decision reaches its author three ways: an inbox in the web UI, the
`pending_decisions` MCP tool, and `GET /api/me/link-decisions`. All three show
the href as written beside the href it would become, so the change is
reviewable rather than an unexplained "fix it" button.

Rewriting the link edits the document containing it, which is why it is offered
rather than done: an automatic repair would produce a revision on every linking
document, attributed to whoever did the rename.

The notification carries the old and new addresses from `document_path_history`, so the prompt reads
*"points at `runbooks/deploy.md`, which moved to `ops/deploy.md`"* rather than a bare "stale link".

### The moved document's own links

A relative href is resolved from the folder of the document it is written in, so moving a document
changes what its `../notes/y.md` names even though its body did not change. Left alone, its links
would quietly point at addresses where nothing lives. So the move rewrites them, in the same
transaction and the same revision as the move itself, with a summary that says so: *"Rebased 3
relative links after the move from wiki:a/page to wiki:b/c/page"*.

- A link that reached a document still reaches it. `../notes/y.md` from `wiki:a/` becomes
  `../../notes/y.md` from `wiki:b/c/`, and the target's backlinks do not change.
- A link whose target had already moved away is pointed at where the target is now, since it is
  being rewritten anyway.
- A link to an address nothing occupies keeps naming that address, so it still resolves the day a
  document appears there.
- A link to the document itself follows it.

Only what the move would break is touched. Root-relative hrefs (`/ops/deploy.md`) mean the same from
anywhere. External URLs, `#anchors`, images, and anything inside a code block or code span are not
links to documents. A relative href that still names the same address from the new folder (every
link, when a document is only renamed) is left exactly as written.

This is not the rewrite ruled out above. It edits only the document being moved, by the person
moving it, who can edit it anyway (`ExplainMove` asks for the same access as `ExplainWrite`), and it
changes no link's meaning: it keeps the meanings from changing. Raw is the exception. Its content is
evidence and is never rewritten, so a raw document's relative links are read from its new folder as
written.

Documents moved one after another, as when a folder is reorganised page by page, still reach each
other whichever goes first. The second to move finds the first at its new address and writes that.
The first could not know where the second was going, so its link names the address the second
left. That address resolves through `document_path_history` as `stale_text`, and the link's author
is asked about it like anyone else's.

## Deletion

Deletion is a tombstone: `deleted_at` is set, the document leaves search and listings, and the row
and its full history survive.

```
documents              deleted_at=now(), purge_after=now()+30d
document_revisions   + 1   change_kind='delete'
events               + 1   document.deleted
   └─ resolver: inbound edges → broken  (target_id retained; a link not read yet
                breaks when it is read, see Resolution)
   └─ notifier: one per linking author, requires_decision=true
   └─ indexer: removed from Typesense
```

Each author chooses:

| Choice | Effect |
|---|---|
| **Repoint** | Edit the link to a different document. Normal revision; edge re-resolves. |
| **Drop** | Remove the link from the body. Normal revision; edge disappears. |
| **Restore** | Clears the tombstone. Every `broken` edge returns to `resolved` and the related notifications resolve themselves. Available only to people who could read the document before it was deleted — see [permissions.md](https://codna.me/docs/permissions.md). |

`target_id` is kept on broken edges precisely so restore is a one-step reversal rather than a
re-resolution that might land somewhere else.

**Only people who could read the document before deletion see any of this.** An author who linked to
a document they never had access to is shown nothing: no decision, no title, no indication the
target ever existed. Their link is treated as one that never resolved. Otherwise a guessed address
would become an existence oracle — see [permissions.md](https://codna.me/docs/permissions.md#links-must-not-reveal-what-they-point-at).

**Purge** hard-deletes tombstoned rows after the retention window, and only once no inbound edge is
still awaiting a decision. Someone else's unresolved decision keeps the row alive — if it were
purged underneath them, their only remaining option would be "drop", and the choice to restore would
have been silently taken away.

## Backlinks

```sql
SELECT d.*, l.state, l.link_text
FROM document_links l
JOIN documents d ON d.id = l.source_id
WHERE l.target_id = $1 AND d.deleted_at IS NULL
```

Then ACL-filtered for the viewer. A person sees backlinks only from documents they can read — the
count they see is the count they can open, not a redacted total that reveals how many hidden
documents cite this one.

## Suggested links

Linking is the agent's job to propose and the user's to decide. CoDNA never creates an edge on its
own.

**Candidate generation** — in order of signal strength:

1. **Vector similarity.** A kNN query in Typesense, available only when an embedding provider is
   configured. Catches documents that discuss the same thing in different words, which nothing else
   here can.
2. **Tag overlap.** `tags && $1` against the GIN index.
3. **Co-citation.** Documents frequently linked alongside this one — two documents cited together by
   several others are usually related, even with no vocabulary in common.

Without an embedding provider, 1 drops out and 2 and 3 still work. Nothing is required to start; see
[search.md](https://codna.me/docs/search.md). Tag overlap and co-citation are what is built; co-citation is weighted double,
because one shared tag like `engineering` says little, while two documents cited together by several
others usually belong together.

**The decision loop:**

```
agent proposes  →  link_suggestions (status='pending', with a rationale)
                        │
        user accepts ───┤─── user rejects
                        │           └──▶ status='rejected'  — never proposed again
                        ▼
        the SOURCE DOCUMENT is edited to contain a real markdown link
                        │
                        ▼
        normal revision → outbox → resolver → document_links edge
```

Accepting a suggestion does not write an edge directly. It edits the body, and the edge follows from
the body as it does for every other link. **The document text is always the truth about what links
to what** — so an export contains every relationship, and there is no hidden graph that disagrees
with what a reader sees.

Accepting appends the link under a `## Related` heading rather than weaving it into the prose. CoDNA
does not know which sentence it belongs in, and guessing would put words in the author's mouth; a
list at the end is honest about being an addition, and the author can move it. Accepting is skipped
when the document already links to the target — compared by where links *resolve*, not by how they
are spelled, since `rollback.md` and `./rollback.md` are the same link.

Proposing needs read access to both ends: an agent can notice two documents belong together without
being able to edit either. Deciding needs write access to the source, and so the suggestion lands in
the inbox of whoever can edit it. Nothing is proposed *from* `raw`, which is never edited, so a link
from it could only ever be rejected.

Surfaces: `related_documents`, `propose_link` and `decide_link_suggestion` over MCP, with pending
suggestions included in `pending_decisions`; the inbox in the web UI; and `actions.suggestLink` in
automations, which goes through the same service and the same rules.

The unique constraint on `(source_id, target_id)` is what makes rejection durable. Without it an
agent re-proposes the same link on every pass and users learn to dismiss suggestions unread, which
costs more than the feature is worth.
