Estimate: 14m · Depends on: 2.2.1
Add lib/workflows/defaultWorkflow.ts — a typed constant defining the v1 default per-project workflow: six statuses covering the full lifecycle including a non-terminal blocked state and a terminal cancelled state (the two most common admin-added statuses in real Jira/Linear installs, baked into the default so every project validates the multi-terminal-status + non-linear-graph paths from day one).
The six statuses (key, label, category, isInitial):
todo · "To Do" · todo · initialblocked · "Blocked" · todo · — (non-terminal "can't proceed" state; complements 1.4.3's work_item_link.is_blocked_by relation — the link expresses a specific blocking issue, the status expresses "blocked, full stop" including external blockers)in_progress · "In Progress" · in_progress · —in_review · "In Review" · in_progress · —done · "Done" · done · —cancelled · "Cancelled" · done · — (terminal — "won't do / duplicate / out-of-scope"; covered by finding #21's readiness predicate via category = 'done')The transition graph (12 transitions, restricted-mode default):
todo→in_progress, in_progress→in_review, in_review→donetodo→blocked, in_progress→blocked, blocked→todo, blocked→in_progress (block from either active state; unblock returns to either)in_review→in_progress, in_progress→tododone→in_progress, cancelled→todo (cancellation is reversible; the most common ask after "won't do" turns out to be a real fix)todo→cancelled, in_progress→cancelled, in_review→cancelled, blocked→cancelled (any non-terminal state can cancel) — that's 4 more, bringing the total to 15 transitionsThen extend projectsService.createProject to call workflowsService.seedDefaultWorkflow(projectId, tx) inside the same transaction as the project insert, so a project either has its workflow or doesn't exist.
Why a typed constant + service-layer seed, not a SQL migration INSERT: the migration can't set app.workspace_id for an arbitrary project; the seed must run under the request context where the GUC is already bound. The service-layer seed also keeps the default editable post-creation (users can rename "In Review", reorder, delete the back-transition, etc.) without forcing a migration. Same pattern as Story 1.2's owner-membership row written by insertWorkspaceWithOwner — application code writes the relational shape; the migration only creates the empty tables.
Workflow policy: default seed sets project.workflow_policy_mode = 'restricted' and writes the six transition rows above. A project starts guarded; an admin can flip to 'open' via 2.2.5's settings UI without deleting the transition rows (they're kept so a flip back to 'restricted' restores the curated graph).
lib/workflows/defaultWorkflow.ts exports the typed default (6 statuses, 15 transitions, the initial-status flag on todo, position values via the fractional-indexing helpers from Story 1.4).workflowsService.seedDefaultWorkflow(projectId, tx) inserts the six workflow_status rows + fifteen workflow_transition rows inside the supplied transaction client; never opens its own transaction.projectsService.createProject calls seedDefaultWorkflow after the project insert, in the same $transaction; rolling back the project insert also rolls back the workflow seed.createProject ends with 6 statuses + 15 transitions; the todo row has isInitial = true; any other call to set a second initial-status fails with the partial-unique violation from 2.2.1.getTerminalStatusKeys on a default-seeded project returns new Set(['done', 'cancelled']) — the two terminal statuses out of the box (proves the multi-terminal path is exercised on day one, not only after admin customization).backfillDefaultWorkflow(projectId) service method (called only by an admin/CLI path in this Story; production has no projects pre-existing this Story yet).lib/services/projectsService.ts + lib/repositories/projectsRepository.ts — Story 1.3's createProjectlib/workspaces/insertWorkspaceWithOwner.ts — the existing service-seed pattern this mirrorslib/workItems/positioning.ts or equivalent) — for workflow_status.positionmotir-core/CLAUDE.md — transaction-client passing rule (write repos require tx)'done' to category = 'done'