11.3.9 `GET /api/v1/projects/{projectKey}/ready` — the ready set in DISPATCH rank, with each row's dependency edges
The endpoint that makes external agent orchestration possible without MCP: "what can I pick up right now, and what does finishing it unblock?" — the reason 11.3 is worth shipping to an integrator at all.
⚠️ Readiness is COMPUTED, and this route does not compute it
An item is ready when it is a childless leaf, in a todo status, with every is_blocked_by blocker terminal AND every ANCESTOR ready — the parent-ready cascade. workItemsService.listReady implements exactly that, top-down by layer (collectReadyLeaves), and returns rows already sorted by the dispatch rank (type asc, priority desc, key asc): leaf-most kinds first, priority breaking the type tie, key breaking the final tie.
A flat "all its own blockers are done" check is a different and wrong answer, and it is the answer a route that re-derives readiness would give. So: call the service. Do not filter, re-sort, re-rank or post-process its result; the endpoint's whole value is that an agent loop and the product's own /ready page can never disagree about what is ready. Do not import from lib/mcp/ to get there either — the MCP tool is a reference SHAPE, not a dependency; the two transports align through the service.
Note what the ORDER is worth: it is the dispatch rank, so items[0] is what an agent should take next. Paging it by any other key silently destroys that, which is why 11.3.2's service-positioned cursor exists — the v1 cursor here wraps the shipped (kind, priority, key) seek-after position.
The dependency-edge block is a second, BATCHED read
Each row carries dependencies: { blockedBy, blocks } — { key, title, status } per edge, key being the MOTIR-<n> identifier — from workItemsService.getDependencyEdgesForItems, which resolves a whole page in two queries and is TOTAL (a row with no edges gets two empty arrays, never undefined). This is what lets a client see downstream impact without N follow-up calls, and it is why the block lives at the transport rather than on ReadyItemDto: widening the DTO would ship an edge payload to the /ready page, which renders dependency state its own way. 11.3.1 Q4 records that a bounded, constant-count page projection is a permitted second service call — never a per-row read.
For a ready row blockedBy is terminal by definition (that is what makes it ready); blocks is the payload that matters — what this item unblocks, i.e. why it is worth doing first.
What to build
lib/api/v1/ready/schema.ts— the ready-row response schema + field-by-field mapper, including the edge block. Decide deliberately what ofReadyItemDtois public:descriptionExcerptis a ~200-char plain-text excerpt, whiledescriptionMdis populated only for manual rows (the shipped payload-size split) — shipping a field that is null for most rows for a reason no client can see is worse than omitting it.type/executor/kind/priorityare closed vocabularies and take the compile-time totality guards.GET /api/v1/projects/{projectKey}/ready—readscope, cursor-paged, with the shipped optional filters (kinds,priority,assigneeIdincluding its tri-state unassigned bucket) exposed or deliberately not.
Reads only. The claim/dispatch operation (claimNextReady) is a work_items:write status flip behind the CI-credit gate and is a deliberate non-goal of this story; nothing here defers work to it.
Acceptance criteria
- The endpoint exists, declares
scope: 'read', and returns the declared schema's output. - The response set and ORDER equal
workItemsService.listReady's for the same project and filters — asserted by calling both and comparing, not by re-implementing the expectation. - A fixture where the parent-ready cascade excludes an item whose OWN sibling blockers are all done is asserted explicitly: the item must be ABSENT. This is the exact case a flat blocker check gets wrong, and it is the reason this endpoint may not be re-derived.
- Every row carries
dependencies: { blockedBy, blocks }with the shipped key names, both arrays always present, andkeyas theMOTIR-<n>identifier — including a row with no edges, which gets two empty arrays rather than a missing field. - The edge projection is two queries for the whole page, not one per row — asserted by query count or by a spy on the batched reader, since an N+1 here is invisible until a 100-row page.
- Paging preserves the dispatch rank across page boundaries;
limitdefaults to 50 and clamps to 100 even though the underlying read allows 200. - A malformed cursor is a 422 (the shipped
InvalidReadyCursorErrormaps deliberately, rather than escaping as a 500); a cursor past the tail is an empty 200 page. - An empty ready set is 200 with empty
items, never a 404; an unknown or cross-workspaceprojectKeyis a 404. - No route re-derives readiness, and no v1 route imports from
lib/mcp/— both asserted as guards, not by review. - Unit tests ship with the route; every new file holds the ≥90% per-file coverage floor.
- ONE PR against
motir-core.
Context refs
lib/services/workItemsService.ts—listReady(and its documented ready predicate + cascade),getDependencyEdgesForItems(the two-query batched projection, and its totality guarantee).lib/workItems/readyFilter.ts—ReadyListFilter, the dispatch sort order,encodeReadyCursor/decodeReadyCursor/InvalidReadyCursorError,READY_DEFAULT_LIMIT/READY_MAX_LIMIT.lib/dto/ready.ts—ReadyItemDto, and the recorded 7.0.3 split explaining whydescriptionMdis manual-rows-only.lib/mcp/tools/listReady.ts·lib/mcp/dependencyEdges.ts— the reference shape and the recorded reason the edge block lives at the transport. Read, do not import.lib/dto/workItems.ts—WorkItemDependencyEdgesDto/WorkItemEdgeSummaryDto, the edge entry shape.- The readiness cascade rule (parent-ready + sibling blockers) —
plan-rules.md§ Ordering follows the dependency arrow. - Blockers: 11.3.1 (Q1 cursor + Q4 the projection call), 11.3.2 (the cursor primitive). Edge projection shipped by 7.9.0f. Parent story: 11.3.