Team knowledge

A knowledge page that stays close to work

A page is a node with no state, and its links are edges you can delete by editing a sentence.

A wiki rots because nothing in the work points back at it. The page is written once, the tasks that depend on it live in a second product, and the connection between them is a URL pasted into a description field that nobody re-checks. TopDo removes the seam by removing the second product. A page and a task are the same row in the same table: a page is a node whose state is NULL, work is a node with a state, and the operation that turns one into the other is called set_state.

An engineering lead writing architecture notes at a laptop
A page stays current when the link is data.

A page is a node with no state

The nodes table carries one state column with exactly three non-null values — open, closed and blocked — plus NULL. The code reads NULL as a page rather than as work. The operation registry documents set_state as “Set task state or turn the node into a page”, and the workspace overview counts every state IS NULL row under the key documents. Writing a page is not a separate surface with its own permissions and its own search; it is a node you never gave a state.

What puts a node in the execution queue is its type, not its state. A workspace is seeded with exactly two node types: doc, with is_action: false, and task, with is_action: true. list_queue inner-joins node_types and requires t.is_action, so an untyped node never appears there at all. Know that ordering before you improvise. Clearing the state of a node still typed task leaves its queue_rank in place, and list_queue only excludes closed, so the node stays in the queue as a stateless row. To take it out, change the type to doc with update_node.

type: docThe decision and its reasoningMarkdown body, comments, labelstype: task, is_actionThe work that implements itState, assignee, orderingone node model
Same row. Only type and state differ.

Write the body for the index it becomes

body_md is Markdown, and it is half of the search index. tsv is a stored generated column on nodes: setweight(to_tsvector('english', title), 'A') concatenated with setweight(to_tsvector('english', body_md), 'B'), GIN-indexed. Two consequences follow directly. Title terms outrank body terms in ts_rank_cd, and nothing else on the node is indexed at all — not labels, not metadata, not comments.

So the title is not decoration. A decision filed as “Q3 sync notes” is unreachable by anyone searching for the constraint it resolved; the same decision titled “Retry budget for the billing webhook” is found on the first query. Put the vocabulary a new joiner would type into the title and the opening lines of the body, and keep the rest of the reasoning underneath it.

You never see the index itself. publicNode strips tsv and claim_hash from every API response, so the column is an effect you observe through search, never a field you read back and check. Treat the title as the only lever you have over ranking.

Wikilinks are edges, not text

A URL pasted into a description is a string, and a string cannot be traversed, counted or invalidated. In TopDo the link syntax inside body_md is parsed on write and reconciled into rows in the edges table. Three forms are recognized, and each produces a specific edge type.

The three forms and the edge each one creates
[[nd_7Kq2wZ3mB9xT4vL1nR6sPd]]
  -> edges row, type 'reference', origin 'parsed'

[[verifies::nd_7Kq2wZ3mB9xT4vL1nR6sPd]]
  -> edges row, type 'verifies', origin 'parsed'

![[nd_7Kq2wZ3mB9xT4vL1nR6sPd]]
  -> edges row, type 'embed', origin 'parsed'

syncParsed runs on every write that carries body_md — on create, and on any update_node whose field set includes body_md. It computes the set of links the current text implies, inserts each missing edge with origin='parsed', then deletes every origin='parsed' edge out of that node which the text no longer implies. Deleting the sentence deletes the edge, in the same transaction, with no cleanup job and no orphan to sweep up later.

A relationship you can revoke by deleting a sentence is a relationship that cannot quietly go stale.

The boundaries are silent rather than loud, and you should know all four. A link to a node that does not exist or has been soft-deleted is skipped. A link to the node itself is skipped. A link whose type is declared acyclic and would close a cycle is skipped. And because reconciliation only runs when body_md is part of the write, creating the target node afterwards does not retroactively create the edge — you have to rewrite the body. None of these raises an error, so verify with links rather than assuming.

Only a parseable relation can be written in the body

The typed form works only when the link type carries parseable: true. syncParsed loads SELECT name, acyclic FROM link_types WHERE workspace_id=$1 AND parseable and drops every parsed link whose type is missing from that map. Of the eleven seeded link types, six are parseable: reference and embed, which back the bare and ! forms, plus verifies, fulfills, mitigates and duplicates.

Five are not, and one of them matters more than the rest. implements is the only edge type that change propagation walks. When a node's title or body_md actually changes, a BEFORE UPDATE trigger bumps content_version, and flushImpacts follows implements edges outward, writing {source_id: content_version} into each affected node's pending_changes and reopening work that was closed. That is the mechanism which makes a page keep its grip on the work. You cannot get that edge from the body.

Nor can you enable it. implements is a system link type, and update_link_type — the relation-type verb, not the update_type used for node types — throws 422 immutable, “System link types are immutable”, before it reaches the field list. So [[implements::nd_…]] written into a task body creates nothing and reports nothing. Create that edge with the link operation instead, and grep your bodies for the string if you suspect somebody has been writing it.

Your own relations are a different matter. create_link_type accepts parseable at creation, and update_link_type can flip it on any non-system type, both under the admin scope and the admin role. Name the relation your workspace actually argues about — supersedes_policy, decided_by — mark it parseable, and from then on it is written as prose and stored as a row.

Comments create edges too, down to depth 8

syncParsed does not read the node body alone. It selects body_md from every live comment on the node and parses those as well, so a wikilink dropped into a discussion becomes a real edge on the node it was written under. Comment creation, update and deletion each call syncParsed on the parent node, which means removing the comment removes the edge it introduced.

Threading has a hard floor. A reply must belong to the same node as its parent, and a reply below depth 8 is refused with 422 depth_limit, “Comment depth cannot exceed 8”. That cap is a useful forcing function: when an argument is eight levels deep, the conclusion belongs in the body, not in the thread.

There is a sharper reason to move it. tsv covers title and body_md only, so a conclusion that exists solely in a comment thread is invisible to full-text search. The comment carries edges and history; the body carries the current agreed state and the searchable text. Keep the division deliberate rather than accidental.

Grow the page out of the work

Most useful pages are discovered, not planned. Half a dozen tasks turn out to be one topic, and the topic deserves a home. wrap_nodes takes between 1 and 500 distinct children that share a parent and creates a wrapper above them, and that wrapper is inserted with state: null — a page by construction. It takes the rank of the first selected child and inherits its is_template flag.

The constraint is contiguity. The selected ids must form an unbroken run in rank order under that parent, or the call fails 422 not_contiguous, “Wrap requires a contiguous run of siblings”; children that do not share the destination parent fail 422 bad_children. Reorder first with move, then wrap. Leave the wrapper untyped or type it doc and it stays out of list_queue.

dissolve_node is the reverse for a page that turned out to be a folder. It soft-deletes the node and promotes every child to the node's parent, preserving relative order by re-ranking them into the gap the parent occupied. A childless node is refused with 422 no_children. Between the two operations the outline is malleable without anyone exporting and re-pasting a document.

Search will find the page, on these terms

search always runs three candidate queries, the first two capped at 100 rows and the third bounded by the number itself: ft ranks tsv with ts_rank_cd against websearch_to_tsquery('english', …); tg takes trigram similarity on the title only; nm matches the per-workspace node number exactly, accepting either #123 or 123. A fourth, vec, runs only when a query vector was produced. The candidates are fused by Reciprocal Rank Fusion — sum(1.0 / (60 + rnk)) — and the final ordering puts an exact number match first, then fused score, then updated_at.

Be clear about what that fourth signal is. The default embedding provider is local-hash-v2: each token is hashed with sha256, the first four bytes choose one of 1536 buckets, the fifth byte's low bit chooses a sign, and the vector is L2-normalized. There is no learned model and no embedding API client anywhere in the backend. It is a lexical signal in vector form. It will not retrieve “rollback policy” from “how do we undo a release”, and you should not plan your titles as though it might.

The failure mode is quiet as well. embedQuery swallows provider errors and returns null, which drops the vec query from the fusion without changing the response shape. Results still come back, ranked by the three lexical signals. Write the words people will search for; that is the part of retrieval you control.

What to check this week

Every check below runs against a workspace you already have, through operations that already exist, and each one returns an answer you can act on the same day.

  1. Open your most-cited page and call links. Read the origin column on each row: parsed rows are revocable by editing text, manual rows need an explicit delete_edge.
  2. Search for that page using only the words a new joiner would type. If a title weighted A and a body weighted B do not surface it, the title is wrong — fix the title, not the search.
  3. Grep your task bodies for [[implements::. Every occurrence is inert, because implements is not parseable and is immutable, so it can never be made so. Replace each one with a link call.
  4. Count the comment threads whose conclusion never reached a body_md. Each is outside tsv and therefore outside search until somebody moves it into the body.

An agent reads all of this through the same surface you do. REST and MCP expose the same registered operations under scoped keys, so an agent with the read scope can call links and search and receive exactly the payload above, including origin. One asymmetry worth knowing: backlinks is an HTTP route rather than a registered operation, so it is absent from MCP; an agent key can still call GET /api/v1/nodes/:node_id/backlinks over REST, and MCP-only tooling reads .inbound from links instead.

What TopDo does not do is run the agent. It stores the page, the work, the edges between them and the audit of every change; execution, prompts and models stay on your side, and built-in dispatch is gated off by default. The page does not stay current because a tool is watching it. It stays current because the work cannot point at it through anything other than a row you can see, count and delete.