Estimate: 16m · Depends on: 2.2.1
Add lib/services/workflowsService.ts + lib/repositories/workflowsRepository.ts shipping the read surface every later consumer needs. The service is the only doorway — repositories are single-Prisma-op leaves per CLAUDE.md, services own DTO shaping + tenant-context.
Read methods on the service (all gated by an explicit workspaceId per finding #26):
getWorkflow(projectId, workspaceId): returns { statuses: WorkflowStatus[], transitions: WorkflowTransition[], policyMode: 'restricted' | 'open' }, statuses ordered by position.listStatusesByProject(projectId, workspaceId): convenience for board columns + pickers.getStatusByKey(projectId, key, workspaceId): returns WorkflowStatus | null — the lookup work_item.status resolves through.getTerminalStatusKeys(projectId, workspaceId): Set<string>: returns every status key whose category is done. This is the surface that resolves finding #21 — workItemsService.isReady and workItemLinkRepository.countOpenBlockers swap their 'done' literal for this set in 2.2.6.canTransition(projectId, fromKey, toKey, workspaceId): Promise<boolean>: returns true if policyMode === 'open' OR a workflow_transition row exists for (from, to); also true if fromKey === toKey (no-op moves are always legal). Reads the curated transition set via the repository.Per finding #26: every public service method takes workspaceId explicitly and the repository methods filter WHERE workspaceId = $ws rather than trusting RLS — the dev/CI superuser bypasses RLS, so the explicit filter is the actual gate, and the (forced) RLS from 2.2.1 is the defense-in-depth backstop. Mirrors Story 1.4.8's pattern.
Repository methods are pure single-op reads (findStatuses, findTransitions, findStatusByKey, findProjectPolicyMode); no joins beyond what Prisma's relation includes deliver.
workspaceId parameters and DTO-shaped returns (no raw Prisma types leaked to callers).workspaceId explicitly (defense in depth on top of RLS); a cross-workspace findStatuses(otherProjectId, wrongWorkspaceId) returns [].getTerminalStatusKeys returns the set of category === 'done' keys — verified against the default seed (returns new Set(['done', 'cancelled']) since 2.2.2 ships both as terminal); after a test-side insert adding a 'wont_fix' status with category done, returns new Set(['done', 'cancelled', 'wont_fix']).canTransition covers all four matrix cells: open mode → true for any (from, to); restricted mode + transition row exists → true; restricted mode + no row → false; fromKey === toKey → true regardless of mode.workItemsService + workItemRepository — the 4-layer + explicit-workspaceId shape this mirrorsjobsDashboardService.listByWorkspace — the explicit-workspaceId-filter precedentmotir-core/CLAUDE.md — DTO mapping owned by service; repos are single-op leaves