Estimate: 18m
The read that backs the whole list: every non-archived issue in a project, assembled into the nested forest the tree-table renders. New workItemsService.getProjectTree(projectId, filter, ctx) returning WorkItemTreeNodeDto[] — roots ordered by key asc, each node carrying its children (same order) plus the columns the rows show: kind, identifier, title, status, assigneeId, depth, and hasChildren. Reuses Story 1.4's recursive-CTE pattern: add a workItemRepository.findProjectForest(projectId, workspaceId, filter?) single-op read (one round-trip, no N+1), then the service nests the flat rows by parentId preserving sibling key order. Map to DTOs in lib/mappers/workItemMappers.ts.
Explicit workspace gate (finding #26). The shipped findByProjectFiltered filters only projectId + archivedAt; the forest read carries an EXPLICIT workspaceId on both the anchor and the recursive step (mirrors findByProjectAndKinds / findAncestors) so a cross-workspace row can't leak even with RLS inert. The service resolves the project's tenant and rejects a cross-tenant projectId (reuse ProjectNotFoundError → 404, the existing no-existence-leak shape).
Filter semantics — context-preserving. The optional filter (kind · status · assigneeId · case-insensitive text on identifier+title) matches rows, but a matching DESCENDANT RETAINS its ancestors (rendered muted/non-matching) so the tree stays navigable — the standard tree-filter behavior, not a flat WHERE that orphans children. Text search is a scoped contains (identifier + title only) — full-text / cross-project search is Epic 6, not this Story.
getProjectTree returns the project's non-archived issues nested by parent, roots and siblings ordered by key asc, each node exposing kind/identifier/title/status/assigneeId/depth/hasChildren.workspaceId filter on anchor + recursive step; a cross-workspace projectId 404s (no row/title leak).[]; a no-filter call returns the full forest.workItemRepository.findSubtree / findAncestors (1.4 / 2.4.3) — recursive-CTE + dual-side workspaceId pattern to mirrorfindByProjectFiltered + listWorkItems + WorkItemSummaryDto — the flat read this generalizes (reconcile, don't duplicate)lib/mappers/workItemMappers.ts, lib/dto/workItems.ts, ProjectNotFoundErrormotir-core/CLAUDE.md — 4-layer · single-op repo · finding-#26 workspace gate