# How to Configure Workflow Rules for Task Status Transitions in Kaneo

> Learn how to configure workflow rules for task status transitions in Kaneo. Understand how Kaneo enforces allowed transitions using the workflow_rules table and the canTransition service.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: how-to-guide
- Published: 2026-08-06

---

**Kaneo uses workflow rules stored in the `workflow_rules` table to enforce which task status transitions are allowed within a workspace, validating each change through the `canTransition()` service function in [`apps/api/src/workflow-rule/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts).**

Workflow rules in Kaneo let you control how tasks move through your project lifecycle. Each rule defines a permitted path from one status to another, with optional conditions that must be met before the transition completes. This guide walks through the configuration process using Kaneo's actual API layer, database schema, and frontend hooks.

---

## What Are Workflow Rules in Kaneo?

A **workflow rule** is a database record that governs task status changes. The rule system prevents unauthorized transitions—for example, blocking a task from moving directly from "Backlog" to "Done" without passing through "In Progress."

The core components are:

- **`fromStatus`** — the current task status
- **`toStatus`** — the desired new status
- **`conditions`** — optional JSON conditions for additional validation
- **`workspaceId`** — scope for the rule

These fields are defined in [[`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts)](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts), where the `workflow_rules` table uses CUID2 for primary keys and foreign keys to workspaces.

---

## Database Schema for Workflow Rules

The persistence layer lives in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts). Understanding this structure helps you configure rules correctly:

```typescript
// apps/api/src/database/schema.ts
export const workflowRules = sqliteTable("workflow_rules", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => createId()),
  workspaceId: text("workspace_id")
    .notNull()
    .references(() => workspaces.id, { onDelete: "cascade" }),
  fromStatus: text("from_status").notNull(),
  toStatus: text("to_status").notNull(),
  conditions: text("conditions", { mode: "json" }),
  createdAt: integer("created_at", { mode: "timestamp" })
    .$defaultFn(() => new Date()),
});

```

The `conditions` column stores flexible JSON for custom validation logic—such as requiring an assignee, minimum time estimate, or specific label presence.

---

## Creating Workflow Rules via the API

The workflow rule service in [[`apps/api/src/workflow-rule/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts)](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts) handles creation and evaluation. Here's how to expose a creation endpoint using Hono and Valibot:

```typescript
// apps/api/src/workflow-rule/index.ts
import { db } from "@/database";
import { workflowRules } from "@/database/schema";
import * as v from "valibot";

const createRuleSchema = v.object({
  workspaceId: v.string(),
  fromStatus: v.string(),
  toStatus: v.string(),
  conditions: v.optional(v.record(v.string(), v.any())),
});

export async function createWorkflowRule(
  payload: v.InferOutput<typeof createRuleSchema>
) {
  const [rule] = await db
    .insert(workflowRules)
    .values({
      workspaceId: payload.workspaceId,
      fromStatus: payload.fromStatus,
      toStatus: payload.toStatus,
      conditions: payload.conditions ?? null,
    })
    .returning();

  return rule;
}

```

Expose this through a Hono route:

```typescript
// apps/api/src/routes/workflow-rules.ts
import { Hono } from "hono";
import { validator } from "hono-openapi/valibot";
import { createWorkflowRule, createRuleSchema } from "@/workflow-rule";

export const workflowRuleRoutes = new Hono()
  .post(
    "/",
    validator("json", createRuleSchema),
    async (c) => {
      const payload = c.req.valid("json");
      const rule = await createWorkflowRule(payload);
      return c.json(rule, 201);
    }
  );

```

---

## Evaluating Transitions with the Workflow Rule Service

The critical function for enforcement is `canTransition()` in [`apps/api/src/workflow-rule/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts). This is called by the task status update controller before persisting any change:

```typescript
// apps/api/src/workflow-rule/index.ts
import { eq, and } from "drizzle-orm";

export async function canTransition(
  workspaceId: string,
  fromStatus: string,
  toStatus: string,
  task: Task
): Promise<boolean> {
  const rules = await db
    .select()
    .from(workflowRules)
    .where(
      and(
        eq(workflowRules.workspaceId, workspaceId),
        eq(workflowRules.fromStatus, fromStatus),
        eq(workflowRules.toStatus, toStatus)
      )
    );

  // No rule found = transition not allowed
  if (rules.length === 0) return false;

  for (const rule of rules) {
    if (!rule.conditions) return true; // No extra checks needed

    // Evaluate JSON conditions against task
    const conditions = rule.conditions as Record<string, any>;

    if (conditions.requireAssignee && !task.assigneeId) {
      continue; // This rule's conditions not met, try next
    }
    if (conditions.minEstimate && (task.estimate ?? 0) < conditions.minEstimate) {
      continue;
    }
    if (conditions.requiredLabels) {
      const taskLabels = task.labels ?? [];
      const hasAllLabels = conditions.requiredLabels.every((l: string) =>
        taskLabels.includes(l)
      );
      if (!hasAllLabels) continue;
    }

    return true; // All conditions satisfied
  }

  return false;
}

```

This function powers the `GET /workspaces/:id/workflow-rules?fromStatus=...` endpoint, which the frontend calls to determine available transitions for a task.

---

## Enforcing Rules in the Task Controller

The status update controller in [[`apps/api/src/task/controllers/update-status.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/task/controllers/update-status.ts)](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/task/controllers/update-status.ts) integrates workflow validation:

```typescript
// apps/api/src/task/controllers/update-status.ts
import { HTTPException } from "hono/http-exception";
import { canTransition } from "@/workflow-rule";
import { getTaskById, updateTaskStatus } from "@/task";

export async function updateTaskStatusCtrl(c: Context) {
  const { id } = c.req.param();
  const { status: newStatus } = c.req.valid("json");
  const user = c.get("user");

  const task = await getTaskById(id);
  if (!task) throw new HTTPException(404, { message: "Task not found" });

  // Verify workspace membership...

  const allowed = await canTransition(
    task.workspaceId,
    task.status,
    newStatus,
    task
  );

  if (!allowed) {
    throw new HTTPException(403, { message: "Transition not allowed by workflow rules" });
  }

  const updated = await updateTaskStatus(id, newStatus);
  
  // Emit activity event
  await publishEvent("task.status_changed", {
    taskId: id,
    from: task.status,
    to: newStatus,
    by: user.id,
  });

  return c.json(updated);
}

```

The validation uses the schema from [[`apps/api/src/schemas.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/schemas.ts)](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/schemas.ts):

```typescript
// apps/api/src/schemas.ts
import * as v from "valibot";

export const UpdateTaskStatusSchema = v.object({
  status: v.string(), // Validated against workspace statuses separately
});

```

---

## Frontend Integration with TanStack Query

The web application fetches allowed transitions and performs status updates through typed fetchers. The pattern in [[`apps/web/src/fetchers/task/update-status.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/fetchers/task/update-status.ts)](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/fetchers/task/update-status.ts) looks like:

```typescript
// apps/web/src/fetchers/task/update-status.ts
const API_URL = import.meta.env.VITE_API_URL;

export async function fetchAllowedTransitions(
  workspaceId: string,
  fromStatus: string
): Promise<string[]> {
  const params = new URLSearchParams({ fromStatus });
  const resp = await fetch(
    `${API_URL}/workspaces/${workspaceId}/workflow-rules?${params}`,
    { credentials: "include" }
  );
  if (!resp.ok) throw new Error("Failed to fetch transitions");
  const rules = await resp.json();
  return rules.map((r: any) => r.toStatus);
}

export async function updateTaskStatus(
  taskId: string,
  newStatus: string
): Promise<Task> {
  const resp = await fetch(`${API_URL}/tasks/${taskId}/status`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status: newStatus }),
    credentials: "include",
  });
  if (resp.status === 403) {
    throw new Error("This status transition is not permitted");
  }
  if (!resp.ok) throw new Error("Failed to update status");
  return resp.json();
}

```

Wrap these in TanStack Query hooks for React integration:

```typescript
// apps/web/src/hooks/mutations/use-update-task-status.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTaskStatus } from "@/fetchers/task/update-status";

export function useUpdateTaskStatus(taskId: string) {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (newStatus: string) => updateTaskStatus(taskId, newStatus),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["task", taskId] });
      queryClient.invalidateQueries({ queryKey: ["tasks"] });
    },
  });
}

```

---

## Sample Workflow Configuration

Here's a complete example configuring a typical Kanban workflow:

```typescript
// Seed script using the workflow rule service
import { createWorkflowRule } from "@/workflow-rule";

const WORKSPACE_ID = "workspace_xxx";

async function setupKanbanWorkflow() {
  // Backlog → In Progress (requires assignee)
  await createWorkflowRule({
    workspaceId: WORKSPACE_ID,
    fromStatus: "Backlog",
    toStatus: "In Progress",
    conditions: { requireAssignee: true },
  });

  // In Progress → Code Review (no conditions)
  await createWorkflowRule({
    workspaceId: WORKSPACE_ID,
    fromStatus: "In Progress",
    toStatus: "Code Review",
  });

  // Code Review → Done (requires no open subtasks)
  await createWorkflowRule({
    workspaceId: WORKSPACE_ID,
    fromStatus: "Code Review",
    toStatus: "Done",
    conditions: { maxOpenSubtasks: 0 },
  });

  // Allow reverting: Code Review → In Progress
  await createWorkflowRule({
    workspaceId: WORKSPACE_ID,
    fromStatus: "Code Review",
    toStatus: "In Progress",
  });
}

```

---

## Summary

- **Database layer**: Workflow rules store `fromStatus`, `toStatus`, and JSON `conditions` in [`apps/api/src/database/schema.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/database/schema.ts)
- **Service layer**: `canTransition()` in [`apps/api/src/workflow-rule/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts) evaluates rules against tasks
- **API layer**: Controllers enforce rules before persisting status changes, returning 403 for violations
- **Client layer**: TanStack Query hooks fetch allowed transitions and handle mutations with automatic cache invalidation

---

## Frequently Asked Questions

### How do I add custom conditions to a workflow rule?

Store arbitrary JSON in the `conditions` column via `createWorkflowRule()`, then extend `canTransition()` in [`apps/api/src/workflow-rule/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/workflow-rule/index.ts) to evaluate your custom keys against task properties. The existing implementation shows the pattern for `requireAssignee`, `minEstimate`, and `requiredLabels`.

### What happens if no workflow rule exists for a transition?

The `canTransition()` function returns `false` when no matching `(workspaceId, fromStatus, toStatus)` row exists, causing the controller to throw an `HTTPException(403)`. Kaneo defaults to deny-all unless explicitly permitted.

### Can users bypass workflow rules through direct API calls?

No. All status changes route through `updateTaskStatusCtrl` in [`apps/api/src/task/controllers/update-status.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/api/src/task/controllers/update-status.ts), which unconditionally calls `canTransition()` before database updates. Client-side UI restrictions are for convenience; the server enforces the actual policy.

### How do I migrate existing tasks when changing workflow rules?

Workflow rules apply only to transition attempts, not retroactively. Tasks retain their current status regardless of rule changes. To bulk-migrate, temporarily create permissive rules or use a service account with elevated privileges that bypasses standard controller validation.