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-3557

16 Roadmap: a level draws edges whose BLOCKED end is not on it — "Not in an epic" redraws the whole EPIC chain as 12 anonymous "blocked elsewhere" ghosts, and the root draws a grouped task as one

Done
Description

Type · code · Repo motir-core · One PR. Parent · parentless at the ROOT. Every candidate ancestor is done (MOTIR-3490, MOTIR-3493), and we only plan forward. Discovered in · manual dogfooding of /roadmap (whole-project scope), 2026-08-26. Two screenshots: the root level, and the level behind the "Not in an epic" door.

The symptom

  1. Drilling Roadmap › Not in an epic shows the 6 unparented rows plus ~12 hatched red "blocked elsewhere" ghost cards, chained to each other by ~15 blocked elsewhere edges. Every one of them is anonymous — identifier , title falling back to the literal "Blocked across stories". None of them is about the 6 items in the group. They are the project's root EPICS, and the graph drawn between them is the epic roadmap's own dependency chain.
  2. The root level draws MOTIR-3493 (a task, parentless, done) as a hatched "blocked elsewhere" card beside the epics — a card that is simultaneously inside the group node one hop away. The DEPENDENCIES legend renders, but no arrow is attached to it: its edge points at a node that is not drawn.

Root cause — VERIFIED, one cause, two faces

buildWorkItemLevel's edge loop (components/planning/workItemLevel.tsx:184-230) classifies an edge on one test — is the BLOCKER on this level? It never asks whether the BLOCKED end is. So an edge whose blocked end is off-level still pushes a cross dep and mints a GhostAnchor for its blocker.

Until MOTIR-3490 that test was sufficient, because the wire guaranteed it: findBlockedByEdges reads where: { fromId: { in: itemIds } } (lib/repositories/workItemLinkRepository.ts:285-295), so every edge in a level payload has its blocked end on that level by construction. MOTIR-3490 broke the guarantee in two places, and neither restored it:

  • The grouped level (components/planning/WorkItemRoadmap.tsx:274-285) narrows items to the grouped rows but hands over edges: root?.edges ?? [] — the ROOT level's whole edge list, most of whose edges belong to rows that are not on this level at all.
  • The root level (workItemLevel.tsx:154-177) moves the grouped rows off onLevel. An edge whose blocked end was one of them now points at nothing.

The anonymity is the same partition, one layer down: an epic is ON the root level, so it never appears in the root read's offLevelBlockers (lib/services/workItemsService.ts:3554-3570) and there is no stub for it in offById. GhostAnchor then renders identifier: stub?.identifier ?? '—' and title ?? t('defaultTitle') (components/planning/WorkItemNode.tsx:455-507) — hence / "Blocked across stories".

Reproduction — measured on the live tenant, 2026-08-26

skeleton over MOTIR returns 24 root rows: 18 epics + 6 non-epic (MOTIR-3490, -3493, -3500, -3522, -3524, -3547), which is the 6 items the group node reports. Reading blockedBy on all 24:

  • 19 epic→epic blocked_by edges among the 18 epics (MOTIR-68←1, 155←68, 211←155, 263←68, 326←211,263, 464←326, 653←326, 673←464,2200, 726←653,673, 1675←464, 1850←326, 2200←464,1850, 2254←326, 3293←464, 3328←3293), over 12 distinct blockers.
  • 1 non-epic edge: MOTIR-3490 blocked_by MOTIR-3493, both done, both grouped.

Feed that into the two paths and the screenshots fall out exactly:

  • Not in an epic (items = the 6, edges = all 20): 12 distinct off-level blockers ⇒ 12 anonymous anchors; the 15 edges whose blocked end is itself a blocker render ghost→ghost; the 4 targeting 726 / 1675 / 2254 / 3328 (blocked but never blocking) have no node and dangle; the one real arrow is 3493 → 3490.
  • Root (items = the 18 epics, the 6 grouped off): the only edge with a grouped blocker is 3493, which gets a named anchor via the stub back-fill at workItemLevel.tsx:167-177 — and its target 3490 is grouped too, so the dep names a node that is not drawn. One hatched card, no arrow.

Fix direction

Restore the invariant in the classifier, not at one call site — it is the general rule and it repairs both faces at once: in buildWorkItemLevel, an edge whose blocked end is not in itemIds is not this level's edge — skip it entirely (no deps entry, no crossBlocked, no anchor) before the blocker test runs. Then scope what the grouped branch passes, so the level is handed only the edges it owns rather than relying on the classifier to discard them.

Not in scope, stated so it is not silently absorbed: in 'project' scope the off-level path never consults stub.isDone (workItemLevel.tsx:199 gates that on sprint scope only), so a satisfied cross-container dependency still draws as a red warning — which is why two done cards produce a red card at the root at all. That is the shipped MOTIR-1379 framing (project scope = plan-shape signal, not readiness), and changing it is a design decision, not this fix.

Acceptance criteria

  1. buildWorkItemLevel drops an edge whose blocked end is not on the level: given a fixture of 2 epics + 2 grouped rows where the only edge runs between the two grouped rows and groupNonEpicRoots is on, the returned deps is empty and nodes contains no ghost-anchor node.
  2. Given the grouped level's inputs — items = the grouped rows, edges = the whole root edge list including epic→epic edges — the result contains zero ghost-anchor nodes for those epics, and deps holds only the edges whose blocked end is one of the grouped rows.
  3. No ghost anchor reachable from either path renders with the '—' / defaultTitle fallback. AMENDED 2026-08-26 during the run — the original criterion contradicted shipped, deliberately-tested behaviour (see The amendment below). As built: no anchor is emitted for an id the level READ itself carries. A fixture states each level's node-id set WHOLE, so an extra id is a failure — the anonymity was a SYMPTOM of anchoring a row that is on the level, and the fix is not to name those anchors but not to emit them.
  4. The shipped MOTIR-1331 signal is unweakened: a fixture where a level row is blocked by a genuine off-level item still yields one named GhostAnchor, one cross dep, and that row flagged crossBlocked.
  5. components/planning/WorkItemRoadmap.tsx's NOT_IN_EPIC_ID branch passes only the edges whose blocked end is one of the rows it passes, named in the PR body.
  6. design/roadmap/design-notes.md DECISION 4 gains what the drilled level's EDGE set is — that the level draws only edges whose blocked end is one of its members, and what becomes of an edge that leaves the group. The section currently settles the member card face and is silent on the edges, which is where this defect entered.
  7. prettier --check clean and the touched vitest suites green; the diff touches components/planning/**, tests/components/** and design/roadmap/design-notes.md only.

Context refs

  • components/planning/workItemLevel.tsx:154-177 the partition + the offById stub back-fill; :184-230 the edge loop this card changes; :199 the sprint-only isDone arm.
  • components/planning/WorkItemRoadmap.tsx:274-285 — the NOT_IN_EPIC_ID branch that hands over the root's whole edge list.
  • components/planning/WorkItemNode.tsx:455-507GhostAnchor and its '—' / defaultTitle fallbacks.
  • lib/repositories/workItemLinkRepository.ts:285-295findBlockedByEdges, the fromId in itemIds predicate that used to make the invariant free.
  • lib/services/workItemsService.ts:3554-3570offLevelIds / findRoadmapBlockerStubs, why an on-level epic has no stub.
  • design/roadmap/design-notes.md § The ROOT level's NON-EPIC rows, DECISION 4 — the design of record for the drilled level.
  • tests/components/ProjectRoadmapCanvas.test.tsx, tests/components/roadmapAutoDrillGate.test.tsx — the existing suites over this surface.
  • MOTIR-3490 (the defect whose fix introduced this), MOTIR-3493 (its design card).

The amendment — a criterion of this card that was FALSIFIED, on the record

AC 3, as authored, asserted a property of shipped code that a grep falsifies. tests/components/workItemLevel.test.tsx:193 is a deliberate, shipped test — "an off-level blocker with NO stub still anchors, named by its bare id" — whose own comment states the intent: "The stub list is best-effort: a blocker the read could not resolve still gets an anchor, so the edge never dangles — it just has nothing but an id to say."

So an anchor rendering the '—' / defaultTitle fallback is correct when the read genuinely could not resolve a blocker, and it stays reachable after this fix: an edge whose blocked end IS on the level and whose blocker findRoadmapBlockerStubs dropped still anchors anonymously, by design. A criterion forbidding it outright would have been discharged by deleting a shipped guarantee.

Per the decision-authority ladder (rung 2 — shipped code outranks a card's prose), the criterion is amended rather than built. What it was reaching for is the real invariant and is what AC 1 and 2 already assert: the anonymity was never the defect, it was the TELL that an on-level row was being anchored. The card was authored, and this was caught, in the same session; recorded rather than quietly narrowed because a dropped criterion and an amended one look identical in the diff and are opposite in kind.

Advisory disposition (validate_work_item, at create)

likely-self-blocking-design — design criterion 6 against surface criterion 5. Disposed: it does not apply, and no design card is owed. The gate exists to stop a card improvising UI no design depicts. This surface IS designed — design/roadmap/root-non-epic-rows.mock.html and design-notes.md DECISION 4 are shipped — and criterion 6 does not design anything: it records into the notes a RULE they were silent about. Nothing in criteria 1-5 draws anything new. The fix only STOPS nodes and edges being drawn, and every treatment it preserves (the GhostAnchor face, the cross edge, the crossBlocked ring) is already drawn in design/roadmap/edges.mock.html and specified in design-notes.md. There is no unspecified pixel in this card.

Resolution

Open.

Status
Done
Type
Bug