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:
id + workspaceId (using the existing tenant-gated read from Story 1.4.8); 404 if missing or cross-tenant.projectId; calls workflowsService.getStatusByKey(projectId, toStatusKey, workspaceId); throws UnknownStatusError (code UNKNOWN_STATUS, → 422) if no such status in this project's workflow.workflowsService.canTransition(projectId, fromKey, toKey, workspaceId); throws IllegalTransitionError (code ILLEGAL_TRANSITION, → 422) on false. Error message names the offending (from, to) pair.status (free-form string column, unchanged shape from 1.4) and emits the existing work_item_revision row 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.$transaction (status write + revision row), same pattern as 1.4.4's updateWorkItem.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.)
issuesService.updateStatus + the two typed errors (UnknownStatusError, IllegalTransitionError) shipped, both surfacing the offending pair in the message.createIssue updated to read the initial status from the workflow rather than hardcoding 'todo'; if the project has no initial status (corrupt seed), throws NoInitialStatusError (code NO_INITIAL_STATUS, → 500 — server invariant violation).$transaction); a forced revision-insert failure rolls back the status change.UnknownStatusError (tenant-gate fires first).updateStatus(id, currentKey)) succeeds without writing a revision row — same idempotency rule revisions already follow elsewhere.lib/services/issuesService.ts (built in 2.1.2/2.1.3)lib/services/workItemsService.ts — Story 1.4.4's updateWorkItem as the multi-write transaction patternwork_item_revision writer — the existing diff-emission pathlib/workspaces/errors.ts — the typed-error precedent (code + 422 mapping)