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.
origin/main, 2026-08-25)workItemsService.getProjectRoadmap (lib/services/workItemsService.ts:3374) resolves one level via
workItemRepository.findProjectTreeLevel(projectId, workspaceId, parentId, DEFAULT_SORT, { take: TREE_LEVEL_MAX_TAKE, offset: 0 }, sprintId, tx) (:3411-3427).
With parentId = null this returns every root row regardless of kind — there is no kind predicate anywhere on the root path.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." So epic | story | task | bug all qualify, and ALLOWED_CHILD_TYPES in lib/issues/parentRules.ts:64 agrees.fetchRoadmapLevel (lib/planning/roadmapClient.ts) maps rows to RoadmapLevelItem and ProjectRoadmapCanvas (components/planning/ProjectRoadmapCanvas.tsx) renders one node per row. No grouping, no kind filter, no lane.TREE_LEVEL_MAX_TAKE = 200 (lib/services/workItemsService.ts:828) with DEFAULT_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.--el-tint-mint wash (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.
epic row in the project, the roadmap root level renders no bug, story or task node as a direct sibling of the epics — they are reachable only by drilling into a single grouped node labelled "Not in an epic". (Was: bug only, node named "Defects". Widened by design decisions 1 and 2.)epic, and the truncated state is visible as a + N more tile carrying Showing N of M and 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 change DEFAULT_SORT.)bug remains legal to create: enforce_work_item_kind_parent and assertValidParent are unchanged by this card, and creating one still succeeds.?scope=sprint) applies the same grouping, under the predicate parentId === null && kind !== 'epic' — a committed parentless defect does not reappear as a bare root node, and a committed member story/subtask, which is a root only because the sprint view re-rooted it, stays on the road. (Design decision 6; a bare kind !== 'epic' rule here would have been destructive.)TREE_LEVEL_MAX_TAKE. [A] Plus one asserting the sprint-scope discrimination in 5.design/roadmap/root-non-epic-rows.mock.html + .png, and design/roadmap/design-notes.md § The ROOT level's NON-EPIC rows — the design that outranks this card (MOTIR-3493, merged ddd9ebe4).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 carries kind AND parentId, so grouping needs no new wire field).components/planning/workItemLevel.tsx — buildWorkItemLevel and the ORIGIN_ID precedent for a synthetic node; components/planning/WorkItemRoadmap.tsx — loadLevel's ORIGIN_ID intercept, the shape the grouped level's read reuses.components/planning/ProjectRoadmapCanvas.tsx — the node renderer and the decorative count 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) and countProjectTreeLevel (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.tests/components/ProjectRoadmapCanvas.test.tsx, tests/integration/work-items/project-roadmap.test.ts, tests/planning/roadmapClient.test.ts.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.