diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs
index e841d21..15b1257 100644
--- a/crates/heph-core/src/sqlite/mod.rs
+++ b/crates/heph-core/src/sqlite/mod.rs
@@ -345,6 +345,27 @@ impl Store for LocalStore {
}
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result {
+ // The derived/internal link kinds are never created by hand: a manual
+ // `wiki` row would be silently reconciled away on the next body write
+ // (the body is the source of truth), and canonical-context / log-of
+ // are task-creation internals. Internal materialization goes through
+ // `links::add` directly and is unaffected.
+ match link_type {
+ LinkType::Wiki => {
+ return Err(Error::InvalidArg(
+ "wiki links are derived from the body — add [[]] to the node's body \
+ instead (e.g. heph context --append)"
+ .into(),
+ ))
+ }
+ LinkType::CanonicalContext | LinkType::LogOf => {
+ return Err(Error::InvalidArg(format!(
+ "{} links are internal task attachments and cannot be added by hand",
+ link_type.as_str()
+ )))
+ }
+ _ => {}
+ }
let now = self.clock.now_ms();
links::add(&self.conn, &self.owner_id, now, src_id, dst_id, link_type)
}
diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs
index c11f4a1..34b0889 100644
--- a/crates/heph-core/tests/tasks_and_links.rs
+++ b/crates/heph-core/tests/tasks_and_links.rs
@@ -502,3 +502,23 @@ fn reparent_project_moves_detaches_and_guards_cycles() {
assert!(s.reparent_project(&heph, Some(&task.node_id)).is_err());
assert!(s.reparent_project(&task.node_id, None).is_err());
}
+
+#[test]
+fn add_link_rejects_derived_and_internal_link_types() {
+ let mut s = store();
+ let a = s.create_node(NewNode::doc("A", "")).unwrap();
+ let b = s.create_node(NewNode::doc("B", "")).unwrap();
+
+ // `wiki` rows are derived from the body (and reconciled away on the next
+ // body write); canonical-context / log-of are task-creation internals.
+ for t in [LinkType::Wiki, LinkType::CanonicalContext, LinkType::LogOf] {
+ let err = s.add_link(&a.id, &b.id, t).unwrap_err().to_string();
+ assert!(
+ err.contains("cannot be added by hand") || err.contains("derived from the body"),
+ "{t:?}: {err}"
+ );
+ }
+
+ // The explicit relationship types still work.
+ s.add_link(&a.id, &b.id, LinkType::Blocks).unwrap();
+}
diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs
index 4ec55e4..58f20e3 100644
--- a/crates/heph/src/main.rs
+++ b/crates/heph/src/main.rs
@@ -297,7 +297,8 @@ enum LinkAction {
src: String,
/// Destination node id.
dst: String,
- /// Link type: blocks|parent|tagged|in-project|context-of|…
+ /// Link type: blocks|parent|tagged|in-project|context-of. (wiki links
+ /// are derived from `[[…]]` in the body and can't be added by hand.)
link_type: String,
},
}
diff --git a/docs/changelog.d/task-sweep-no-manual-wiki-links.misc.md b/docs/changelog.d/task-sweep-no-manual-wiki-links.misc.md
new file mode 100644
index 0000000..d9c2f4d
--- /dev/null
+++ b/docs/changelog.d/task-sweep-no-manual-wiki-links.misc.md
@@ -0,0 +1 @@
+Manual creation of derived/internal link types is rejected: `links.add` (and so `heph link add`) errors on `wiki` (those rows are materialized from `[[…]]` in the body and were silently reconciled away on the next body write), `canonical-context`, and `log-of`. To make a durable wiki link, put `[[dst]]` in the body.
diff --git a/docs/changelog.d/task-sweep-wiki-links-doc.doc.md b/docs/changelog.d/task-sweep-wiki-links-doc.doc.md
deleted file mode 100644
index 3f47358..0000000
--- a/docs/changelog.d/task-sweep-wiki-links-doc.doc.md
+++ /dev/null
@@ -1 +0,0 @@
-New explanation card [[wiki-links]]: wiki-link semantics — body text vs the links table, why `heph link add … wiki` links get reconciled away, resolution tiers, expand/collapse projection, and how links surface in each client.
diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md
index 45f1cb0..bbc81dc 100644
--- a/docs/explanation/explanation.md
+++ b/docs/explanation/explanation.md
@@ -13,4 +13,3 @@ Background context and design decisions.
- [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap
- [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up
- [[hub-spoke-data-evolution]] — why op-based sync lets most new features skip migrations, and when a coordinated SQLite migration is actually required
-- [[wiki-links]] — wiki-link semantics: body text vs the links table, `heph link add … wiki` caveats, resolution tiers, expand/collapse projection, and per-client surfaces
diff --git a/docs/explanation/wiki-links.md b/docs/explanation/wiki-links.md
deleted file mode 100644
index ebbe413..0000000
--- a/docs/explanation/wiki-links.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: Wiki-Link Semantics
-modified: 2026-06-09
-tags:
- - explanation
----
-
-# Wiki-Link Semantics
-
-How `[[wiki-links]]` work in the data model, what `heph link add … wiki` actually does, and how links surface in each client. Companion to the frozen build record in [[v1-prototype-tech-spec]] (§5, §6, §8.4).
-
-## Two layers: body text and the links table
-
-A wiki-link lives in two places that are kept in sync in one direction:
-
-1. **The body text** of a node — the source of truth. At rest a link is stored canonically as `[[NODEID]]`, or `[[NODEID|custom text]]` when the author gave explicit display text. Legacy `[[Name]]` links still resolve by title until `heph migrate-links` is run.
-2. **The `links` table** — a materialized index. Every time a body is written, `sync_wiki_links` re-extracts the body's links, resolves each target, and *diffs* the node's `wiki`-type link rows to match: rows for targets no longer in the body are tombstoned, rows for new targets are added. This index is what powers backlinks.
-
-The direction matters: **body → links, never links → body.**
-
-## What `heph link add wiki` means
-
-`heph link add … wiki` inserts a `wiki` row directly into the links table *without touching the body*. Because the body is the source of truth, the next body write on `` reconciles the index against the body and **tombstones the manual row** — the link silently disappears.
-
-So: a manual `wiki` link is at best a temporary annotation on a node whose body never changes, and at worst a footgun. If you want a durable wiki-link, **put `[[dst]]` in the body**. The non-`wiki` link types (`parent`, `blocks`, `tagged`, `in-project`, `canonical-context`, `log-of`) are *only* managed explicitly and are never reconciled against bodies — those are the correct use of `heph link add`.
-
-## Resolution (who does `[[target]]` point at?)
-
-Three tiers, first match wins (tech-spec §8.4):
-
-1. **Node id** — `[[01ABC…]]` resolves to that node if it exists and is live.
-2. **Alias** — the `aliases` table.
-3. **Exact title** — excluding canonical-context docs, because a task and its context doc share a title; `[[Task Title]]` must resolve to the task.
-
-Unresolved targets are not an error: the body keeps the text, no link row is created, and a later body write re-syncs once the target exists.
-
-## Display projection: expand on read, collapse on write
-
-Bodies at rest store bare ids; humans read names:
-
-- **Expand (read path):** `node.get` rewrites `[[NODEID]]` → `[[NODEID|Current Name]]`, so a rename is always reflected.
-- **Collapse (write path):** `update_node` rewrites `[[NODEID|text]]` → `[[NODEID]]` when `text` still equals the target's current title (an auto-label), preserving genuine custom labels. An unchanged read→write round-trips exactly.
-- **Export** also expands ids to readable labels, so exported markdown is human-legible outside heph.
-
-## How links surface in each client
-
-- **heph CLI** — `heph show` / `heph context` print bodies through the expand projection; `heph backlinks` (via the `links.backlinks` RPC) lists incoming links from the materialized index.
-- **heph-tui** — the preview pane renders the selected task's canonical-context body (expanded labels). Links are display text only today; there is no follow-link navigation in the TUI yet.
-- **PWA** — mirrors the TUI's read path through the same hephd RPCs, so it sees the same expanded labels; like the TUI, links are not yet tappable navigation.
-- **hephaestus.nvim** — the richest surface: buffer-backed context docs round-trip through expand/collapse, so `[[id|Name]]` spans are editable text that collapses back to canonical ids on save.