Knowledge structure

One home per node, and a graph for everything else

One parent decides where a node lives; typed edges decide what it affects. A copy breaks both.

Every team that outgrows a folder tree reaches the same fork. A requirement matters to two projects, and the obvious move is to put a copy in each. TopDo refuses that move by construction: a node has exactly one parent_id, and everything else you want to say about it is a row in a separate typed edge table. The tree answers where a node lives. The edges answer what it affects. Keeping those two questions apart is the whole design, and a second copy is what destroys it.

A knowledge lead reorganizing a workspace at a desktop computer
One parent per node. Typed edges relate.

Containment is one parent and one rank

A node’s position in the outline is two columns: parent_id, a nullable self-reference, and rank, a text string that orders it among its siblings. A partial unique index, nodes_ws_parent_rank, covers (workspace_id, parent_id, rank) for rows where deleted_at IS NULL. There is one parent column, so there is one home, and the database will not let two live siblings share a position under it.

Choose that home the way a teammate would search for it, not the way a taxonomy would. It is an organizational choice and it stays cheap to revise: move takes at least one of reparent, before_id or after_id, and refuses with 422 bad_anchor if you give it none. Moving a node below its own descendant fails with 422 cycle, so the tree cannot knot itself.

One move is not cheap to revise. Moving a node under a node with is_template set stamps is_template=true on the entire moved subtree, and there is no reverse path in the code. Templates are the one place where the destination rewrites what you moved into it, so treat that move as a decision rather than a tidy-up.

One parentChildChildChildthe tree says where a node lives; the dashed relations say what it affects
Solid lines contain. Dashed lines relate.

The rank is a base-36 string that rebalances itself

rankBetween computes a midpoint between two neighboring ranks over the alphabet 0-9a-z. It rejects a rank ending in 0 and rejects a pair given out of order. Inserting repeatedly into the same gap makes the midpoint string longer each time, which is the known cost of ordering without renumbering every sibling on every drag.

Once a computed rank exceeds 128 characters, rebalance rewrites the whole sibling list, and it does so in two passes. The first pass sets every row’s rank to ~ followed by its own node id; the second writes the evenly spaced final ranks. ~ sorts above every base-36 character and the node id makes each sentinel unique, so the unique index holds at every intermediate state rather than only at the end. The interrupted move is then re-positioned against the fresh ranks.

queue_rank is the same mechanism applied to one flat, workspace-wide queue, under its own partial unique index. Neither ordering is something you administer. The practical consequence is that reordering never fails for running out of room, and never asks a person to renumber anything.

A relation is a claim, not a filing decision

Edges live in their own table: src_id, dst_id, type, origin, metadata, unique on (workspace_id, src_id, dst_id, type), with type a foreign key into link_types. Creating one is the link operation. It resolves both endpoints as live nodes or returns 404 not_found, rejects a self-link with 422 invalid, and rejects an unknown type with 404 not_found.

Eleven link types are seeded: implements, reference, blocks, supersedes, embed, instance_of and includes as system types, plus verifies, fulfills, mitigates and duplicates. Each carries a forward and a reverse label, so the same row reads as “implements” from one end and “implemented by” from the other. That is a statement about the work, and someone can be wrong about it.

The asymmetry is worth naming. Where a node sits is bookkeeping that any teammate may revise without arguing. Whether a task implements a requirement is an assertion that changes what the system does: impact propagation walks implements edges and nothing else, so that one edge type is what reopens closed work when a requirement’s content_version moves.

A copy destroys the property you were paying for

Duplicating a node to make it appear in two places produces two rows with two ids, two version counters, two content_version counters and two independent edge sets. Nothing in the schema binds them. There is no column that says these two rows are the same thing, because the model’s premise is that they never need to be.

The loss is concrete rather than aesthetic. pending_changes is a map keyed by the id of the node that changed, so an edit to copy A reopens the work that implements A and leaves everything hanging off copy B untouched and quietly stale. Call links on copy B and you see B’s edges only; A’s inbound relations are not there and never will be. Two rows also means two things for search to return and two candidates for the next person to edit, and only one of them is being read.

A second copy is not a second place to see the work. It is a second thing that can be wrong, with no mechanism that will ever tell you which one is.

The edge is what you wanted in the first place. One node, one home, and as many typed relations as the work genuinely has. And where two nodes really did arrive independently and describe the same thing, duplicates is already seeded for exactly that, which records the overlap instead of hiding it.

Read what `links` actually returns

links runs two queries, one per direction, and returns {outbound, inbound}. Each row carries edge_id, type, origin and metadata from the edge, plus id, num, title, state and node_type from the node at the other end. Deleted nodes are excluded and rows are ordered by edge type, then node title.

Create a relation, then read both directions
POST /edges
{"src_id": "nd_…", "dst_id": "nd_…", "type": "implements"}

GET /nodes/nd_…/links
{"outbound": [{"edge_id": "ed_…", "type": "implements",
               "origin": "manual", "metadata": {},
               "id": "nd_…", "num": 118, "title": "Retention policy",
               "state": null, "node_type": "doc"}],
 "inbound": []}

Neither direction is limited and neither is paginated. A hub node with a thousand inbound references returns a thousand rows in one response, so size your client for the hubs you actually have rather than for the median node. origin tells you where the edge came from: manual for a link call, parsed for one derived from [[type::nd_…]] markdown in a body.

backlinks is links with the outbound half thrown away. It runs both queries and returns only inbound, so it costs what links costs and gives you half the answer. It is also an HTTP route without a registered operation, which means it is not reachable over MCP; an agent that wants inbound edges calls links and reads one key.

The graph response hides the edges that leave the slice

The graph endpoint walks containment. It seeds from a root when you give one and from every node with parent_id IS NULL when you do not, excludes deleted and template nodes, and caps recursion at depth 50. max_depth filters the output rows but does not shorten the recursion, so asking for a shallow picture does not make the query cheaper.

Then comes the trap. Edges are fetched in a second query restricted to the node set that was just returned, matching only where both src_id and dst_id are inside it. An edge from a node in your slice to a node outside it is not in the response at all. Scope a graph call to one product area and its nodes will look self-contained, because every relation that crosses into another area has been filtered out of the picture you are reading. That is a property of the request you made, not of the work.

graph/depth has its own ceiling. Its recursive query stops at depth 6, so the max_depth it reports saturates there: a 6 means six or more, and an outline eleven levels deep reports 6 as well. It is a cheap guard against a runaway outline, not a measurement, and it should never be pasted into a report as though it were one.

Use `neighborhood` when you mean the edges

neighborhood is the edge-based walk. It traverses edges in both directions from a seed node, clamps hops to the range 0 to 5, keeps each reachable node once at its minimum depth with DISTINCT ON, and excludes the seed from the result. It is the query to reach for when the question is what surrounds this node in meaning rather than what sits under it in the outline.

Its cycle protection is materially stronger than the tree walks’. It carries a real visited-path array and refuses any next node already on the path, whereas subtree and graph rely on the depth cap alone to terminate. That difference is earned: containment cannot cycle because move rejects it with 422 cycle, while edges can and do, and a bidirectional walk over them would otherwise loop.

So pick the traversal by the question. subtree and graph for where things live, capped at depth 50. neighborhood for what connects to what, capped at 5 hops. links when you want one node’s relations exactly, with no traversal at all.

Four of the eleven link types are yours

Seven seeded link types are marked is_system and are immutable and undeletable; touching one returns 422 immutable. The other four — verifies, fulfills, mitigates and duplicates — are ordinary workspace rows, so a team can relabel them to the words it already uses in review. What you cannot change is the name: only forward_label, reverse_label, acyclic and parseable are updatable, and name is not in that list. Relabel, do not rename.

Deleting a link type that is in use is refused with 422 in_use. The check covers both any edge of that type and any node type whose gated_close rule names it, so a type in real use cannot be pulled out from under existing data. Retire a relation by removing its edges first; the type follows.

The acyclic flag has an honest boundary. Its cycle check is scoped to a single type, so a loop that alternates between two different link types is not detected by anything. Promoting an existing type to acyclic re-scans every edge already carrying it and fails with 422 in_use when those edges already contain a cycle, which is a useful way to learn that a relation you added by hand bent round on itself months ago. Note which types this applies to: implements, blocks, supersedes and includes are seeded acyclic and is_system, so they can neither loop nor be changed. Of the four editable types only duplicates is seeded non-acyclic, so it and your own types are what the promotion is for.

What to check this week

All of these are read-only calls against a workspace you already have, and each one settles a question that is otherwise answered from memory. Run them against the area you argue about most, not against a clean example.

  1. Find a node someone copied. Call links on both rows and compare inbound: the copy with no inbound edges is the one nobody is actually reading, and the other is the record.
  2. Before you conclude from a graph response that an area is self-contained, call links on its three busiest nodes. The graph query drops every edge that leaves the slice you asked for.
  3. If a report says the outline is six levels deep, read it as six or more. graph/depth stops its recursion at 6.
  4. List your link types and mark which four are not is_system. Those are the only labels a workspace can change, and changing a label is not changing a name.
  5. Try promoting duplicates, or one of your own types, to acyclic in a scratch workspace. A 422 in_use is the fastest existing-cycle report you have; the system types refuse with 422 immutable instead.

The recurring failure is not a missing feature. It is two rows that describe one thing, created because a folder could only hold a node once and nobody reached for the edge. The model already separates the organizational question from the semantic one; a copy silently answers both at once, and answers the second one wrong.

TopDo is in private beta. Everything described here is behavior you can read in the endpoints and the schema, including the caps and the places where a response is narrower than it looks. Where a traversal saturates or a query trims its own edges, we would rather you knew the number than trusted the picture.