MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-78

2.2.4 Transition validation + integration with `issuesService.updateStatus`

Done
Description

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; calls workflowsService.getStatusByKey(projectId, toStatusKey, workspaceId); throws UnknownStatusError (code UNKNOWN_STATUS, → 422) if no such status in this project's workflow.
  • Calls workflowsService.canTransition(projectId, fromKey, toKey, workspaceId); throws IllegalTransitionError (code ILLEGAL_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 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.
  • All in one $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.)

Acceptance criteria

  • 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).
  • 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's updateWorkItem as the multi-write transaction pattern
  • Story 1.4.6's work_item_revision writer — 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