How to Configure Workflow Rules for Task Status Transitions in Kaneo

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.

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), 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. Understanding this structure helps you configure rules correctly:

// 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) handles creation and evaluation. Here's how to expose a creation endpoint using Hono and Valibot:

// 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:

// 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. This is called by the task status update controller before persisting any change:

// 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) integrates workflow validation:

// 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):

// 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) looks like:

// 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:

// 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:

// 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
  • Service layer: canTransition() in 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 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, 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →