2.5.1 Project issue-tree read — `getProjectTree` + workspace gate + filter
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.
Acceptance criteria
getProjectTreereturns the project's non-archived issues nested by parent, roots and siblings ordered bykeyasc, each node exposing kind/identifier/title/status/assigneeId/depth/hasChildren.- The forest read carries an explicit
workspaceIdfilter on anchor + recursive step; a cross-workspaceprojectId404s (no row/title leak). - A filter (kind/status/assignee/text) returns matching rows AND their ancestor chain for context; an empty project returns
[]; a no-filter call returns the full forest. - One DB round-trip (recursive CTE), no N+1; archived items excluded; depth bounded by the Story-1.4 cap.
- Vitest (real Postgres): nesting + sibling order at depth ≥3, ancestor-retention under a descendant-matching filter, cross-workspace isolation at the repo layer, empty project.
Context refs
workItemRepository.findSubtree/findAncestors(1.4 / 2.4.3) — recursive-CTE + dual-sideworkspaceIdpattern 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