MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3490

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

Done
Description

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 under run.md's a done design 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 are design/roadmap/design-notes.md § The ROOT level's NON-EPIC rows.

Three clauses moved:

  1. The grouping predicate widened from bug to every non-epic root (decision 1). This card states it two ways — AC 1 said bug, the fix direction below said "non-epic rows" — and the design resolved the contradiction toward the wider rule, because a bug-only predicate leaves a parentless story or task drawing on the road and re-opens this same defect the first time one is filed.
  2. The node's name is "Not in an epic", not "Defects" (decision 2) — "Defects" is false the moment a parentless story or task joins the group, which under (1) is a normal state.
  3. 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 is parentId === null && kind !== 'epic', evaluated at the root level only.

Root cause (verified against origin/main, 2026-08-25)

  1. 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.
  2. 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." So epic | story | task | bug all qualify, and ALLOWED_CHILD_TYPES in lib/issues/parentRules.ts:64 agrees.
  3. The client draws whatever comes back. 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.
  4. The cap is the sharper half. 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.
  5. [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-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.

Acceptance criteria

  1. [A] With at least one parentless non-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.)
  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.
  3. [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 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.)
  4. A parentless bug remains legal to create: enforce_work_item_kind_parent and assertValidParent are unchanged by this card, and creating one still succeeds.
  5. [A] Sprint scope (?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.)
  6. 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, 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:3374getProjectRoadmap, the per-level read; :828TREE_LEVEL_MAX_TAKE.
  • lib/issues/issueListView.ts:70DEFAULT_SORT, which decides which rows the cap discards.
  • lib/planning/roadmapClient.tsRoadmapLevelItem / fetchRoadmapLevel, the client level read (already carries kind AND parentId, so grouping needs no new wire field).
  • components/planning/workItemLevel.tsxbuildWorkItemLevel and the ORIGIN_ID precedent for a synthetic node; components/planning/WorkItemRoadmap.tsxloadLevel'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.tsdeterministicLayout, 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.tsfindProjectTreeLevel (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.
  • 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.

Status
Done
Type
Bug