12 Roadmap root level draws every parentless bug as a top-level node beside the epics, and its 200-row cap then drops the NEWEST epics silently
Type: bug · Parent: none (root sibling — filed parentless deliberately; this card is what makes the parentless placement safe) · Discovered in: the defect-placement rule review (2026-08-25), while establishing where a bug with no dependency edge into its finding story should live · Reported by: Yue.
The roadmap canvas has no notion of kind at its root level. Every parentless work item is drawn as a top-level node next to the epics, ordered by key — so a defect renders between two epics on the opening canvas. Worse, the root read is capped, and the cap is ordered such that overflow discards the newest epics rather than the defects.
This is the constraint currently forcing every logged bug to be given a parent it does not otherwise need. Fixing it separates where a defect lives in the tree from how the roadmap draws it, which are two different decisions.
⚠️ AMENDED 2026-08-25 22:30 — the design blocker OUTRANKS this card
MOTIR-3493's asset merged at 22:25 (motir-core#2280,
ddd9ebe4); this card was authored at 20:35. The design is YOUNGER, so underrun.md's adonedesign blocker that merged AFTER the card was authored OUTRANKS the card rule it is a SPEC and the clauses below that contradict it are amendments, not features to build. Every change is marked[A]with what the design decided. Nothing is silently dropped.The asset is
design/roadmap/root-non-epic-rows.mock.html+.png, and its dispositions aredesign/roadmap/design-notes.md§ The ROOT level's NON-EPIC rows.Three clauses moved:
- The grouping predicate widened from
bugto every non-epicroot (decision 1). This card states it two ways — AC 1 saidbug, the fix direction below said "non-epicrows" — and the design resolved the contradiction toward the wider rule, because a bug-only predicate leaves a parentlessstoryortaskdrawing on the road and re-opens this same defect the first time one is filed.- The node's name is "Not in an epic", not "Defects" (decision 2) — "Defects" is false the moment a parentless
storyortaskjoins the group, which under (1) is a normal state.- Sprint scope needs a second conjunct this card did not anticipate (decision 6). The sprint view re-roots at the topmost in-sprint members — mostly stories and subtasks — so
kind !== 'epic'ALONE would sweep nearly the whole sprint into one node. The predicate isparentId === null && kind !== 'epic', evaluated at the root level only.
Root cause (verified against origin/main, 2026-08-25)
workItemsService.getProjectRoadmap(lib/services/workItemsService.ts:3374) resolves one level viaworkItemRepository.findProjectTreeLevel(projectId, workspaceId, parentId, DEFAULT_SORT, { take: TREE_LEVEL_MAX_TAKE, offset: 0 }, sprintId, tx)(:3411-3427). WithparentId = nullthis returns every root row regardless ofkind— there is no kind predicate anywhere on the root path.- A parentless bug is a legal root.
enforce_work_item_kind_parent(prisma/sql/work_item_triggers.sql:182-189) refuses only one kind at the root: "A subtask is the only kind that may not be a root." Soepic | story | task | bugall qualify, andALLOWED_CHILD_TYPESinlib/issues/parentRules.ts:64agrees. - The client draws whatever comes back.
fetchRoadmapLevel(lib/planning/roadmapClient.ts) maps rows toRoadmapLevelItemandProjectRoadmapCanvas(components/planning/ProjectRoadmapCanvas.tsx) renders one node per row. No grouping, no kind filter, no lane. - The cap is the sharper half.
TREE_LEVEL_MAX_TAKE = 200(lib/services/workItemsService.ts:828) withDEFAULT_SORT = { column: 'key', direction: 'asc' }(lib/issues/issueListView.ts:70). Root rows are therefore truncated from the high-key end, and the highest-key roots are the most recently created epics. There is no cursor and no "more" affordance on the root level, so the drop is silent. - [A] Added by the design pass, from a render of the shipped components. A parentless bug that is READY takes the whole-card
--el-tint-mintwash (MOTIR-1422), so the symptom is stronger than "a defect renders between two epics": on the opening canvas a ready defect is the most visually prominent node on the road.
Current exposure: 18 epics, so ~182 rows of headroom — latent today, not live. It becomes live at 200 root rows, and the first thing lost is whichever epic was created most recently. (No key is named here on purpose: the identity of that row changes every time an epic is added, so naming one would be stale on arrival and would read as a dependency this card does not have.)
Fix direction. Group, do not filter — hiding rows to tidy the canvas would trade a visual bug for a correctness one. At the root level, collapse [A] every non-epic root row into a single synthetic container node ([A] labelled "Not in an epic", carrying its own count and drill-in) so the opening canvas shows epics plus one grouped node. Then make the cap non-silent: [A] a + N more tile in the level's last cell carrying Showing N of M and a Show all action, at every level rather than only the root. Both sites are in motir-core. The design settles the node's face, its decorative status, where its level is served from, and the sort question — see design/roadmap/design-notes.md.
Acceptance criteria
- [A] With at least one parentless non-
epicrow in the project, the roadmap root level renders nobug,storyortasknode as a direct sibling of the epics — they are reachable only by drilling into a single grouped node labelled "Not in an epic". (Was:bugonly, node named "Defects". Widened by design decisions 1 and 2.) - That grouped node drills in: selecting it reveals the grouped rows as its children, each with the same status chip, progress and ready treatment a drilled node has today.
- [A] No root row is discarded to achieve 1 or 2 — a project whose root set exceeds the level cap can still reach every
epic, and the truncated state is visible as a+ N moretile carryingShowing N of Mand a Show all action, at any level rather than only the root. (Design decision 7 chose the tile over a raised cap or a cursor, and deliberately does NOT changeDEFAULT_SORT.) - A parentless
bugremains legal to create:enforce_work_item_kind_parentandassertValidParentare unchanged by this card, and creating one still succeeds. - [A] Sprint scope (
?scope=sprint) applies the same grouping, under the predicateparentId === null && kind !== 'epic'— a committed parentless defect does not reappear as a bare root node, and a committed memberstory/subtask, which is a root only because the sprint view re-rooted it, stays on the road. (Design decision 6; a barekind !== 'epic'rule here would have been destructive.) - Regression tests cover the grouping and the cap: a component or integration test asserts the root level of a project holding epics and parentless non-epic rows yields epics plus one grouped node, and a test asserts every epic is still reachable when the root set exceeds
TREE_LEVEL_MAX_TAKE. [A] Plus one asserting the sprint-scope discrimination in 5.
Context refs
design/roadmap/root-non-epic-rows.mock.html+.png, anddesign/roadmap/design-notes.md§ The ROOT level's NON-EPIC rows — the design that outranks this card (MOTIR-3493, mergedddd9ebe4).lib/services/workItemsService.ts:3374—getProjectRoadmap, the per-level read;:828—TREE_LEVEL_MAX_TAKE.lib/issues/issueListView.ts:70—DEFAULT_SORT, which decides which rows the cap discards.lib/planning/roadmapClient.ts—RoadmapLevelItem/fetchRoadmapLevel, the client level read (already carrieskindANDparentId, so grouping needs no new wire field).components/planning/workItemLevel.tsx—buildWorkItemLeveland theORIGIN_IDprecedent for a synthetic node;components/planning/WorkItemRoadmap.tsx—loadLevel'sORIGIN_IDintercept, the shape the grouped level's read reuses.components/planning/ProjectRoadmapCanvas.tsx— the node renderer and thedecorativecount at:478;lib/planning/projectCanvasModel.ts—deterministicLayout, whose loose band already places an edge-less node below the flow.prisma/sql/work_item_triggers.sql:182— the root-kind rule this card deliberately does not change.lib/repositories/workItemRepository.ts—findProjectTreeLevel(no kind predicate at the root) andcountProjectTreeLevel(the total the truncation tile draws against).app/api/projects/[key]/roadmap/route.ts— the transport; unchanged unless the cap fix needs a cursor param.- Existing coverage to extend:
tests/components/ProjectRoadmapCanvas.test.tsx,tests/integration/work-items/project-roadmap.test.ts,tests/planning/roadmapClient.test.ts.
Resolution
Open. Reopened 2026-08-25 22:27 — this card was flipped done at 22:25 by parentStatusRollupService when its only child, the design card MOTIR-3493, merged (agg.done === agg.total, and the service exempts nothing). None of the criteria above were built at that point; git grep over origin/main finds no grouping and no truncation affordance. The closure was the rollup reading a prerequisite child as this card's content — see the comment thread.