2.2.1 Schema — `workflow_status` + `workflow_transition` + RLS
Estimate: 18m · Depends on: 1.3.1, 1.4.2
Add the two workflow tables to prisma/schema.prisma and ship one Prisma migration that creates both plus their RLS policies, using the same workspace-scoped app.workspace_id GUC pattern Story 1.6.4 established for job_run (see finding #33). Both tables carry an explicit workspaceId column + projectId FK + the Story-1.2 RLS gate, so the typed-status data inherits tenant isolation by structure, not by joins.
Tables and key constraints:
workflow_status:(id, workspaceId, projectId, key, label, category, color, position, isInitial, createdAt, updatedAt).categoryis a Prisma enumStatusCategory { todo, in_progress, done }— the durable Jira-style three-bucket taxonomy.keyis the machine-stable identifierwork_item.statusstores (e.g.'todo','in_review'); unique per project (@@unique([projectId, key])).positionis aDecimal(20,10)matching Story 1.4's column-shape rule (finding #18) — same fractional-indexing path the work-item ordering uses.isInitialis a boolean; partial unique index@@unique([projectId, isInitial]) where isInitial = trueenforces exactly one initial status per project.workflow_transition:(id, workspaceId, projectId, fromStatusId, toStatusId, createdAt).@@unique([projectId, fromStatusId, toStatusId])prevents duplicate transitions. The "any → any" project-policy is NOT stored as N² rows; it's a project-levelworkflow_policy_modecolumn onproject(added in this same migration) with valuesrestricted/open.openmeans transitions are unconstrained (the explicit transition rows are ignored at validation time);restrictedconsults the transition rows. Defaultrestricted. This is the durable shape — Jira and Linear both have an "anything goes" project mode and a "guarded transitions" mode; storing the policy as a project column rather than a flag insideworkflow_transitionkeeps the shape O(transitions) in storage and O(1) to check the policy.
RLS: both tables enable RLS + FORCE ROW LEVEL SECURITY (so even the table owner is gated, per Story 1.4.5's pattern). The policy mirrors work_item's: USING (workspace_id = current_setting('app.workspace_id')::uuid) + the system-admin escape hatch OR current_setting('app.system_admin', true) = 'true' (see finding #33). No FOR SELECT/INSERT/UPDATE/DELETE split — one policy per table covering all four, matching Story 1.6.4.
What this does NOT do: seed default rows (that's 2.2.2's job — done in application code, not a SQL INSERT in the migration, so the seed runs under the prodect_app role and gets the workspace_id GUC set correctly). Also does not change work_item.status's column type — it stays String for v1 portability, with integrity enforced by the service layer (see 2.2.4).
Acceptance criteria
workflow_status+workflow_transition+project.workflow_policy_modeadded in one Prisma migration;prisma migrate devapplies cleanly against a fresh DB.- RLS policies created and FORCED on both tables; the
app.workspace_id+app.system_adminGUC pattern mirrorsjob_run(finding #33). - Partial unique index enforces exactly-one-initial-status-per-project; attempting a second initial-status insert fails with a constraint violation.
@@unique([projectId, key])enforces stable per-project status keys.- An RLS proof test (mirroring
tests/jobs/rls.test.ts) underSET LOCAL ROLE prodect_appdemonstrates: workspace A's session sees only its own statuses; cross-workspace SELECT returns 0 rows; INSERT with a foreignworkspaceIdin the same row is rejected. - No
SQL INSERTin the migration for default rows (seeding is application-layer work in 2.2.2).
Context refs
prisma/schema.prisma— Story 1.3project, Story 1.4work_item- Story 1.6.4's migration
add_job_run_dlq_and_rls— the canonical RLS +app.system_adminescape-hatch shape - Story 1.4.5's
FORCE ROW LEVEL SECURITYmigration onwork_item tests/jobs/rls.test.ts— the role-switch RLS-proof harness this Subtask mirrorsmotir-core/CLAUDE.md— 4-layer rule, repo-write contract- Finding #18 —
Decimal(20,10)position-column shape; finding #33 — GUC namespace