2.2.4 Transition validation + integration with `issuesService.updateStatus`
Estimate: 18m · Depends on: 2.2.3, 2.1.2
Add issuesService.updateStatus(workItemId, toStatusKey, workspaceId, ctx) — the first write into work_item.status that goes through the typed-workflow gate. The method:
- Loads the work item by
id+workspaceId(using the existing tenant-gated read from Story 1.4.8); 404 if missing or cross-tenant. - Reads the work item's
projectId; callsworkflowsService.getStatusByKey(projectId, toStatusKey, workspaceId); throwsUnknownStatusError(codeUNKNOWN_STATUS, → 422) if no such status in this project's workflow. - Calls
workflowsService.canTransition(projectId, fromKey, toKey, workspaceId); throwsIllegalTransitionError(codeILLEGAL_TRANSITION, → 422) on false. Error message names the offending (from, to) pair. - Writes the new
status(free-form string column, unchanged shape from 1.4) and emits the existingwork_item_revisionrow via 1.4.6's existing revision pipeline — the revision diff records the status change in the same shape every other field does, so Epic 5's activity feed surfaces it for free. - All in one
$transaction(status write + revision row), same pattern as 1.4.4'supdateWorkItem.
Why not just a DB CHECK constraint: a free-form status string with a project-scoped legal set + per-project transitions can't be expressed as a static CHECK. A trigger could (and would also catch direct writes), but the cost is high and the benefit small — the service layer is the only writer for production code; the repository tx-required rule from CLAUDE.md keeps the surface narrow. Same risk model as 2.1.2's type-parent rule: the service is the friendly gate; the schema layer just keeps status NOT NULL.
Boundary with Story 2.1's createIssue: the create path (2.1.3) seeds the new issue with the project's initial status (looked up via listStatusesByProject().find(s => s.isInitial)), bypassing transition validation — there's no "from" status on a brand-new row. The pre-existing 'todo' default-status string in work_item.status is removed; the default now comes from the workflow's initial-status row. createIssue in 2.1.3 must be updated by this Subtask to call the workflow lookup. (This was identified as a forward update during 2.1 expansion; calling it out here so 2.2.4 owns the change rather than re-discovering it.)
Acceptance criteria
issuesService.updateStatus+ the two typed errors (UnknownStatusError,IllegalTransitionError) shipped, both surfacing the offending pair in the message.createIssueupdated to read the initial status from the workflow rather than hardcoding'todo'; if the project has no initial status (corrupt seed), throwsNoInitialStatusError(codeNO_INITIAL_STATUS, → 500 — server invariant violation).- Status write + revision write are atomic (one
$transaction); a forced revision-insert failure rolls back the status change. - Vitest under real Postgres: legal restricted transition succeeds + writes a revision row; illegal restricted transition rejected; open-mode project accepts any legal status as a transition target; unknown status key rejected; cross-workspace work-item ID → 404, not
UnknownStatusError(tenant-gate fires first). - No-op transition (
updateStatus(id, currentKey)) succeeds without writing a revision row — same idempotency rule revisions already follow elsewhere.
Context refs
lib/services/issuesService.ts(built in 2.1.2/2.1.3)lib/services/workItemsService.ts— Story 1.4.4'supdateWorkItemas the multi-write transaction pattern- Story 1.4.6's
work_item_revisionwriter — the existing diff-emission path lib/workspaces/errors.ts— the typed-error precedent (code + 422 mapping)- Finding #21 → resolved when 2.2.6 swaps the literal