CoDNA Request access
Contents

Docs How it works

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:

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.

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".

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.

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.

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.

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.

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. 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.