A release plan reads as a sequence because it was typed as a list. Some of that order is real — the verification cannot run before the change exists. The rest is habit, preference, or the shape of last quarter's spreadsheet. TopDo keeps the two apart by giving containment and typed relations different jobs, and then it reads only what you wrote. The ready frontier in list_queue is a predicate over blocks edges, not an interpretation of your intent. Model the dependency and it binds. Leave it implicit and nothing enforces it.

Containment says where the work lives, a relation says what it waits on
Containment is the parent_id column on nodes, ordered among siblings by rank. A node sits in at most one parent — parent_id is nullable, and a root has none — and the recursive walks over that tree carry explicit caps rather than a single shared one: subtree, ancestors and the graph endpoint all stop at depth 50, the depth walk behind GET /graph/depth stops at 6, and the dashboard rollup has no depth cap at all, instead stopping at 10,000 descendants and reporting itself incomplete past that. That is the structure you reorganize: a release node, children for implementation, documentation and release checks, nested as deep as the team finds readable.
A typed relation is a row in edges, carrying src_id, dst_id and type, with type a foreign key into the link_types registry. The unique key is workspace, source, destination and type, so one pair of nodes can hold several relations of different types but never two of the same. Creating a link is an upsert on that key: a repeat is not a duplicate, it rewrites the existing row's origin to manual and replaces its metadata.
The practical consequence shows up the day the release gets re-cut. Moving a task from one release to another is move, and it writes one parent_id and one rank — plus, when the destination parent is itself a template, is_template set true across the whole moved subtree. It leaves queue_rank untouched and does not touch a single blocks edge, because the dependency was never a fact about where the task was filed. Containment is the filing; relations are the claims.
parent_id column. A row in edges.The ready frontier is a predicate, not a judgment
Call list_queue with ready set to true and you get the set the database can defend, every clause of it: the node is live and not a template, its type carries is_action, its queue_rank is not null, its state is open, and there exists no inbound blocks edge whose source is live, has a non-null state, and is not closed. Results come back ordered by queue_rank then id, with limit accepted between 1 and 1000 and defaulting to 200. The operation needs the read scope, so an agent key with read alone can pull the frontier over REST or MCP without being able to write anything.
GET /api/v1/queue?ready=true&limit=50
[
{
"id": "nd_9Kk2wXq7Ra4tB1cD6eF0gH", "num": 412, "type": "task",
"title": "Verify the import error copy", "state": "open",
"parent_id": "nd_3Lm8pQ5vZs1yN7bT2uC4wE", "queue_rank": "a3f",
"pending_changes": {}, "claimed_by": null, "claim_expires_at": null,
"version": 6, "child_count": 0
}
]That query is the whole promise. Nothing in it reads a label, a due date, a milestone field or a comment thread. If a piece of work should not be started yet, the reason has to be a blocks edge from a node the database can see is unfinished, or the frontier will hand it out.
A page cannot block a task
state is open, closed or blocked — or NULL, which the system treats as a page rather than work. The ready predicate requires the blocking source to have a non-null state. Point a blocks edge at a specification page and the edge exists, renders in the graph endpoint, comes back from links, and blocks nothing at all. The same non-null test appears in claim_work and in the close gate, so the page is inert in all three.
This is the trap worth carrying into a release review. A checklist that says the release waits on the signed architecture note, modeled as an edge from the note page, is decoration. If the gate must hold, the blocker has to be a node with a non-null state — an approval task that stays open until somebody closes it — and the page is what that task references.
There is a second way to vanish from the queue. list_queue joins node_types and requires is_action on the type, so a node with no type, or with a type that is not an action, never appears in the queue whatever its dependencies say. Seeded workspaces ship exactly two node types: doc, whose is_action is false, and task, whose is_action is true. Turning is_action on for a type backfills queue_rank for every live node of that type that had none, templates included — the backfill has no template filter, so template nodes come away with a queue position that list_queue will never show.
Ready is not the same as available
The ready filter does not consider pending_changes and does not consider claims. A node can sit on the frontier while a requirement it implements has changed underneath it and while another principal holds an unexpired lease on it. Reading the frontier is not a reservation.
The refusals live on the write path instead. claim_work returns 409 not_ready for work that is not open and for anything carrying is_template, 409 work_claimed when a live claim exists, and 409 not_ready again with "Complete the blocking work first" when a live source with a non-null, non-closed state points at the target through blocks. Closing with a non-empty pending_changes map returns 409 changes_pending, and the map is cleared only by acknowledge_changes submitting exactly the stored revisions; a partial map, an extra key or a stale number each come back as 409 requirements_changed. Plan for a frontier that is a reading aid and a write path that argues back.
Enforcement lives on the node type, not on the link
A blocks edge filters the frontier and refuses a claim. It does not, by itself, stop anyone from closing the blocked node. If you want the close refused, you declare it on the type: the rule vocabulary has exactly one member, and a rule must carry gated_close and a via naming a link type that exists in the workspace, with no other key. An extra key, another rule name or an unknown via is rejected as an invalid gated_close rule.
With that rule in place, closing a node of that type while an unfinished source sits on the named link type fails with 422 close_gated, and the detail carries the rule, the via list and the blockers themselves — id, num, title and state, ordered by num. The blocker query applies the same live, non-null, non-closed test, so a page named through verifies gates nothing here either. A link type that a gated_close rule names cannot be deleted; the registry answers 422 in_use.
The product enforces the graph you wrote, not the sequence you had in mind.
Cycles are caught per link type, and the tree is caught separately
A link type may be declared acyclic, and blocks ships that way. Before such an edge is inserted, a recursive reachability walk runs forward from the destination over edges of that same type, and the insert fails with 422 cycle if it arrives at the source. Promoting an existing type to acyclic re-scans every edge already stored on it and refuses with 422 in_use when the existing relations already contain a cycle.
Read the filter in that query carefully, because it is restricted to one type. A loop that alternates types is not detected: A blocks B, and B verifies A, is accepted, since neither type contains a cycle on its own. If you rely on acyclic to keep a release plan from eating itself, keep the loop you fear inside a single link type, and treat cross-type loops as something a review catches rather than the database.
Containment has its own, separate guard. move refuses to reparent a node below itself or one of its descendants with 422 cycle, and the walk it checks against is the same one that stops at depth 50. Those are two different protections with two different scopes, and neither one covers the other.
Name the relation you mean
Eleven link types are seeded. Seven carry is_system — implements, reference, blocks, supersedes, embed, instance_of and includes — and a system row is immutable and undeletable, answering 422 immutable to an edit. Four more are seeded without it: verifies, fulfills, mitigates and duplicates. Those a workspace can adjust.
What you can adjust is forward_label and reverse_label, the acyclic flag and the parseable flag. The name is not updatable on any link type. So a release team that reads "validates" rather than "verifies" can change the labels the interface shows while every rule, every query and every gated_close via keeps pointing at the same identifier. Choose the name for the system and the label for the reader.
An edge of a parseable type can also be written straight from the prose: a markdown reference of the form [[verifies::nd_...]] becomes an edge with origin set to parsed, reconciled from the body and the node's comments whenever one of those two changes — a node created with a body, a node update that carries body_md among its fields, and every comment create, update or delete — and silently skipped when the type is acyclic and the edge would close a cycle. A write that touches only the title, the labels, the state or the parent reconciles nothing, so a parsed edge survives a rename and a move untouched. verifies and fulfills are both parseable; blocks is not, so a blocking claim is always an explicit link.
A template carries dependencies, not a sequence
run_process copies a template's containment subtree plus whatever it reaches transitively through includes edges, capped hard at 500 nodes per run. It copies the blocks, reference, embed and includes edges that start inside the copied set — the source has to be one of the copies — and remaps the destination only when the destination was copied too, so an edge aimed outside the set is copied still pointing at the original node, and an edge aimed in from outside is not copied at all. It forces is_template false, and normalizes state so that everything which was work starts open. implements is not on that list, so a template's requirement links do not come across. The result is linked back to the template by a single instance_of edge; there is no runs table and no run status.
There is also no step index and no engine that opens the next step when the previous one closes. Order inside a run is tree order plus rank, and dependency inside a run is exactly the copied blocks edges read through the ready frontier. A checklist whose order lived in the numbering of its titles arrives with that rank order intact and nothing enforcing it — every copied item that was work starts open at once — which is the clearest argument for writing the dependencies down before you promote a process to a template.
Checks to run on the release you have open
Every check below is answerable today, against your own workspace, without changing anything. Each one is the same question asked of a different part of the model: is the constraint in the data, or is it still in somebody's head.
- Call
list_queuewithreadytrue and read the first ten rows. Anything there that a colleague would refuse to start is a dependency that was never written as an edge. - For every
blocksedge into your verification work, check the source'sstate. A source with a NULLstateis a page, and a page never blocks. - Check the
typeof everything you expect in the queue. Notype, or a type withoutis_action, means the node is absent fromlist_queueentirely, dependencies and all. - Decide where a close must be refused rather than discouraged, then read the
ruleson that node type. Nogated_closerule there means the close is discouraged and nothing more. - If you rely on
acyclic, confirm the loop you are guarding against lives inside one link type. An alternating loop across two types is accepted.
TopDo is in private beta, and the scope of what it does here is narrow on purpose. It does not run your release. It holds the graph, hands out the ready frontier to whoever reads it, and refuses the writes that contradict what you modeled — which is the whole of the value, because a dependency nobody encoded is a dependency nothing can enforce.