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.
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.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.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:
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.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".
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:
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.MOTIR-3490 blocked_by MOTIR-3493, both done, both grouped.Feed that into the two paths and the screenshots fall out exactly:
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.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.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.
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.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.'—' / defaultTitle fallback.GhostAnchor, one cross dep, and that row flagged crossBlocked.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.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.prettier --check clean and the touched vitest suites green; the diff touches components/planning/**, tests/components/** and design/roadmap/design-notes.md only.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-507 — GhostAnchor and its '—' / defaultTitle fallbacks.lib/repositories/workItemLinkRepository.ts:285-295 — findBlockedByEdges, the fromId in itemIds predicate that used to make the invariant free.lib/services/workItemsService.ts:3554-3570 — offLevelIds / 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.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.
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.
Open.