Product decisions

Keep requirements connected to the build

What TopDo does when a requirement moves under work that is already underway.

A requirement changes on a Tuesday and three implementations quietly stop being correct. Nothing throws an error, because nothing broke — the specification moved and the work did not. TopDo turns that move into a recorded obligation. Editing a requirement’s title or body raises its content_version, walks the implements edges, reopens the closed work underneath, and writes down which source changed and to which revision. It does not rewrite your implementation. It refuses to let the work close until somebody states the revision they read.

A product manager and an engineer reading a changed requirement side by side at a desk
The change is recorded. The call is yours.

Two counters live on every node, and only one means anything

Each row in nodes carries version and content_version, both bigint, both starting at 1. They are not two names for the same idea. The trigger living_node_version increments version on every update of the row without exception — a label edit, a rank change, an assignee swap, a claim heartbeat. It increments content_version only when title or body_md actually changes value, and only then does it enqueue anything.

That distinction decides what your people and your agents should watch. version is the optimistic-concurrency token: complete_work requires expected_version, and a stale one comes back as 409 version_conflict with {expected_version, current_version} in the detail. It is noisy by design, because a claim heartbeat on the same row is enough to move it. content_version is the meaning counter. Only it enqueues impact, and only it ever appears as a value inside a pending_changes map.

So an agent that polls version to detect requirement drift will see a stream of changes that mean nothing, and will learn to ignore it. Read content_version when the question is whether the text moved. Send expected_version when the question is whether you are writing over somebody else. Neither column substitutes for the other.

The signal fires inside the transaction that made the change

living_node_version is a BEFORE UPDATE trigger. When it raises content_version it also upserts a row into impact_queue, keyed on the workspace and the node, carrying the new revision. Because it fires on UPDATE only, creating a node never enqueues impact. A requirement has to already exist and then move before anything downstream hears about it.

Every write in TopDo runs through mutate, which takes a pg_advisory_xact_lock on the workspace and then calls flushImpacts before the transaction returns. Propagation is synchronous and same-transaction. There is no background worker to fall behind, no retry queue to inspect, and no window in which the requirement has moved and the implementations are not yet marked. By the time the edit’s response reaches the editor, every affected node already carries the change.

The cost of that guarantee is worth naming plainly. The edit and the propagation succeed or fail together, so a propagation that hits its ceiling rejects the edit as well. The workspace-wide advisory lock is taken by withOrg, so reads sit in the same queue as writes: one request per workspace at a time, which is a real throughput bound on a busy workspace and the reason the propagation query is capped rather than unbounded.

Propagation follows `implements`, and follows nothing else

flushImpacts runs a recursive query over edges restricted to type='implements', starting from the changed node and walking outward to everything that implements it, transitively. That is the only edge type consulted. reference, blocks, embed, supersedes, verifies and fulfills all exist in the seeded link registry and none of them carries a requirement change.

Containment does not carry it either. Filing an implementation task underneath a requirement page in the outline says where the task lives; it does not say what the task implements. The edge is the claim, and it has a direction: the implementation is src_id, the requirement is dst_id. You create it by calling link with {src_id, dst_id, type: "implements"}. Writing a wiki-style link in the body will not do it, because implements is seeded with parseable false and only parseable link types are derived from Markdown.

Transitivity is the part that pays for the edge discipline. If a test plan implements a component task and the component task implements the requirement, editing the requirement reaches both — in the same pass, in the same transaction, not as a second wave that arrives later. implements is also seeded acyclic, so a link that would close a loop is refused with 422 cycle at the moment you draw it rather than discovered during a walk.

Requirementcontent_version 4Edit raises itcontent_version 5Implementer reopenspending change recordedTransitive implementerreached through implements
One edit reaches both implementers.

Closed work reopens; open and blocked work is left alone

For each affected node the propagation computes one thing about state: a node that is closed becomes open, and anything else keeps the state it had. Work already open stays open. blocked work stays blocked, so a reopen never hides a real dependency. A node whose state is NULL is a page rather than a task, and it is never reopened, because it was never closed.

The reopen is the part teams argue about, and it is the part that earns its place. A closed task is an assertion that the requirement it implements has been satisfied. When the requirement moves, that assertion is no longer supported by anything. Reopening does not claim the work is wrong — it withdraws the claim that the work is finished, and puts the node back where somebody will look at it.

One consequence deserves a guard rail on your side. The ready filter in list_queue adds state='open' and the absence of a live blocks edge; it does not consider pending_changes and it does not consider existing claims. A node that reopened because a requirement moved is therefore back on the ready frontier, and an agent that claims from that frontier without reading pending_changes will start work on the old text. The enforcement in TopDo sits at close time, not at claim time.

`pending_changes` is a latest-seen map, not a log

The column is jsonb, defaults to an empty object, and its public contract is a record of string to number. The key is the id of the source node whose text changed. The value is that source’s content_version at the moment of propagation. One entry per source, and the value is overwritten each time that source moves again.

So if a requirement is edited four times before anybody reads it, the map holds a single entry at content_version 8 — not four entries, not a history, not a diff. You cannot reconstruct what the intermediate wordings said from the map, and the map is not trying to tell you. What it tells you is exactly this: which sources have moved since you last acknowledged them, and where they stand now.

The sequence is recoverable, but from the audit trail rather than the column. Each affected node gets a node.requirement_changed entry written with principal "system", recording the state before and the source_id, source_version and resulting state after. That trail is hash-chained in the same transaction as the change. The trail itself is readable over HTTP: GET /api/v1/events returns audit_events rows in seq order, and an agent key carries the read scope that route requires. Verifying the chain is the part with no HTTP surface — verifyChain runs from the CLI only.

A non-empty map blocks the close

checkClose refuses any transition to closed while pending_changes has a single key, raising 409 changes_pending with the message “Review changed requirements before completing this work”. The same guard runs for set_state and for complete_work, so neither a person closing the node in the interface nor an agent completing a claim can walk past it.

For an agent, that refusal sits at a known position in a fixed evaluation order on complete_work. The request body is validated first, so a missing or non-positive expected_version is 422 invalid before the node is read at all; after that the handler runs 404 not_found, then 409 claim_expired, then 409 not_ready, then 409 version_conflict, then 409 changes_pending, then 422 close_gated. Write the retry logic against that order. version_conflict is worth a re-read and a retry. changes_pending never is — retrying it without reading anything loops until the agent’s rate limit ends the argument.

The block is also the only place where the obligation is enforced. Nothing prevents editing, commenting on, moving or reassigning a node with pending changes. The system constrains one act — declaring the work done — and leaves every other act available while the review is outstanding.

Acknowledge the exact map, or not at all

acknowledge_changes takes {node_id, observed}, where observed is a record of node id to positive integer. Over REST it is a POST to the node’s acknowledge route; over MCP it is a tool call that additionally requires an idempotency_key, like every mutating tool. It needs the write scope, and scopes in TopDo do not nest — a key holding admin does not thereby hold write.

The comparison is equality of the whole map: the same number of keys, and the same value for every key. Anything else is 409 requirements_changed with “Requirements changed again. Read the latest revisions first.” A partial acknowledgement fails. An extra key fails. A stale number fails, which is the case that matters — you read revision 5, worked for an hour, the requirement moved to 6 while you worked, and the acknowledgement is refused rather than silently swallowing a revision nobody read.

MCP tools/call arguments, and the refusal shape from errors.ts
{
  "node_id": "nd_7Qb3xK2mVr9tLp4sWn6Yd0",
  "observed": { "nd_2Hf8cRt5yQw1Bn3vMk7Zp0": 5 },
  "idempotency_key": "ack-nd_7Qb3-2026-09-16T11:04:22Z"
}

{ "error": {
    "code": "requirements_changed",
    "message": "Requirements changed again. Read the latest revisions first."
} }

On success it sets pending_changes to an empty object — the whole map at once, never the entries you happened to name — and audits node.requirements_reviewed. It does not re-close the node. Acknowledging that you read a change is a different act from finishing the work, and TopDo keeps them separate: the caller still calls set_state or complete_work afterwards, and complete_work will still demand a current expected_version. Note also that acknowledge_changes carries no version check of its own.

Shape the graph for the 1,000-node ceiling

The propagation query selects affected rows with a limit of 1,001 and raises 422 impact_limit — “This change affects more than 1,000 nodes; narrow the graph first” — when more than a thousand come back. Because propagation shares the transaction with the edit, the edit is rejected too. You never end up with a requirement at content_version 5 and half its implementers marked.

The ceiling is on the fan-out of one source node, not on the size of the workspace. You reach it by writing a single node that everything in the product claims to implement — a platform principles page, a master specification, a compliance statement wired to every task in sight. The fix is the same modeling move either way: split the source into the parts that actually vary, and point each implementation at the part it implements.

Treat the limit as a design signal rather than a wall. A change that reopens a thousand nodes has not produced a review; it has produced a thousand notifications that a team will clear without reading, which is worse than no signal at all. Keeping fan-out to something a person can actually read in an afternoon is what makes the obligation survive contact with a busy week.

What the product supplies is a review obligation

Nothing in this path edits your implementation. No body text is rewritten, no task is re-scoped, no acceptance condition is re-derived from the new wording. The system’s work is bounded and mechanical: raise a counter when the text moves, walk one edge type, reopen what was closed, record which source moved and to what revision, and refuse the close until somebody names the revision they read. Every judgment about what the change means stays outside that boundary.

Reopening a task is not a verdict that the work is wrong. It is the withdrawal of the claim that the work was finished.

That boundary is also what makes it reasonable to hand an agent a key. TopDo does not run agents and does not judge their output. It gives them a place to read work, claim it and leave an update, through REST and MCP with scoped keys; the models, the prompts and the cost of running them stay with you. An agent reads the new body_md, decides what the change implies, and either does the work or says it cannot — and that conclusion is your agent’s work, recorded under your agent’s identity, not a TopDo assessment.

The honest description of the feature is narrow and that is the point. It converts a change that used to travel by message into a state on the work itself, with a close that will not proceed until the state is cleared deliberately. Everything else — whether the change is trivial wording or a new acceptance condition, whether an hour of work or a week follows — remains a decision a person or their agent makes, and TopDo records which one of them made it.

  1. Draw an explicit implements edge from every implementation node to the requirement it implements, with link; containment and a pasted link carry nothing, and implements is not parseable from Markdown.
  2. Have whatever claims work read pending_changes on the node before it starts, because the ready filter in list_queue does not exclude a node whose requirement moved.
  3. Edit one real requirement’s body_md and count what reopened; if it is in the hundreds, split the requirement before the number reaches the 1,000-node impact_limit.
  4. Point your retry logic at the error code: retry version_conflict after a re-read, never retry changes_pending, and treat requirements_changed as an instruction to fetch the current map and start the review again.
  5. Read the workspace overview, whose changed list returns up to 30 nodes with a non-empty pending_changes, newest first; it is an HTTP route rather than a registered operation, so MCP tooling cannot reach it, while an agent key calling GET /api/v1/overview directly can.