Adding Features
This chapter describes the three most common extension patterns: adding a new entity, adding a new workflow preset, and extending the workflow engine. Each section includes a concrete example and explains the reasoning behind the step order.
Adding a New Entity
Example: Milestone
A Milestone is a date-anchored checkpoint associated with a project. It has a name, an optional description, a target date, and a status.
Step 1: Define the model in tack-core
Open crates/tack-core/src/models.rs and add the struct and any associated DTOs:
#![allow(unused)] fn main() { // ─── Milestone ──────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Milestone { pub id: Uuid, pub project_id: Uuid, pub name: String, pub description: Option<String>, pub target_date: Option<DateTime<Utc>>, pub completed_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } #[derive(Debug, Deserialize, Validate)] pub struct CreateMilestone { #[validate(length(min = 1, max = 200))] pub name: String, pub description: Option<String>, pub target_date: Option<DateTime<Utc>>, } #[derive(Debug, Deserialize, Validate)] pub struct UpdateMilestone { #[validate(length(min = 1, max = 200))] pub name: Option<String>, pub description: Option<String>, pub target_date: Option<DateTime<Utc>>, pub completed_at: Option<DateTime<Utc>>, } }
tack-core is the right home for these types because it is the layer all other crates share. The API crate uses CreateMilestone for deserialization; the DB crate uses it as the input to the repository function. Keeping them in one place avoids duplication.
Step 2: Add the migration in tack-db
Open crates/tack-db/src/migrations.rs. Migration names are numbered sequentially and
never reused — find the highest number already in all_migrations() (grep -o '"[0-9]\{3\}_[a-zA-Z0-9_]*"' crates/tack-db/src/migrations.rs | sort -u | tail -1) and
pick the next one. As of this writing that's 081, so the new migration is 082. Find
the migrations vec in all_migrations() and append:
#![allow(unused)] fn main() { ("082_milestones", &MIGRATION_082[..]), }
Then add the constant near the end of the file:
#![allow(unused)] fn main() { const MIGRATION_082: [&str; 2] = [ "CREATE TABLE IF NOT EXISTS milestones ( id TEXT PRIMARY KEY NOT NULL, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, target_date TEXT, completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )", "CREATE INDEX IF NOT EXISTS idx_milestones_project ON milestones(project_id)", ]; }
Migrations are append-only and run in order. Never edit an existing migration — if you need to change the schema, add a new one.
Step 3: Create the repository module
Create crates/tack-db/src/repo/milestones.rs. Follow the same pattern as other repository files: functions take &SqlitePool, bind parameters, run the query, and return the struct.
#![allow(unused)] fn main() { use chrono::Utc; use uuid::Uuid; use sqlx::SqlitePool; use tack_core::models::{CreateMilestone, Milestone, UpdateMilestone}; pub async fn create_milestone( pool: &SqlitePool, project_id: Uuid, input: CreateMilestone, ) -> Result<Milestone, sqlx::Error> { let id = Uuid::new_v4(); let now = Utc::now(); sqlx::query( "INSERT INTO milestones (id, project_id, name, description, target_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ) .bind(id.to_string()) .bind(project_id.to_string()) .bind(&input.name) .bind(&input.description) .bind(input.target_date.map(|d| d.to_rfc3339())) .bind(now.to_rfc3339()) .bind(now.to_rfc3339()) .execute(pool) .await?; Ok(Milestone { id, project_id, name: input.name, description: input.description, target_date: input.target_date, completed_at: None, created_at: now, updated_at: now, }) } // add get_milestone, list_milestones, update_milestone, delete_milestone }
Step 4: Register in repo/mod.rs
Open crates/tack-db/src/repo.rs and add:
#![allow(unused)] fn main() { pub mod milestones; }
Then add delegating methods to the Repository impl:
#![allow(unused)] fn main() { pub async fn create_milestone( &self, project_id: Uuid, data: CreateMilestone, ) -> Result<Milestone, sqlx::Error> { milestones::create_milestone(self.pool(), project_id, data).await } pub async fn list_milestones(&self, project_id: Uuid) -> Result<Vec<Milestone>, sqlx::Error> { milestones::list_milestones(self.pool(), project_id).await } // get, update, delete … }
This pattern — thin delegating methods on Repository — keeps the struct as the single entry point callers interact with while keeping each entity's SQL isolated in its own file.
Step 5: Create the handler
Create crates/tack-api/src/handlers/milestones.rs:
#![allow(unused)] fn main() { use axum::{ extract::{Path, State}, http::StatusCode, response::Json, }; use uuid::Uuid; use validator::Validate; use tack_core::models::{CreateMilestone, Milestone, UpdateMilestone}; use crate::{error::{ApiError, ApiResult}, router::AppState}; pub async fn create_milestone( State(state): State<AppState>, Path(project_id): Path<Uuid>, Json(input): Json<CreateMilestone>, ) -> ApiResult<(StatusCode, Json<Milestone>)> { input.validate().map_err(|e| ApiError::BadRequest(e.to_string()))?; // Verify project exists state.repo.get_project(project_id).await? .ok_or_else(|| ApiError::NotFound(format!("Project {project_id} not found")))?; let milestone = state.repo.create_milestone(project_id, input).await?; Ok((StatusCode::CREATED, Json(milestone))) } pub async fn list_milestones( State(state): State<AppState>, Path(project_id): Path<Uuid>, ) -> ApiResult<Json<Vec<Milestone>>> { let milestones = state.repo.list_milestones(project_id).await?; Ok(Json(milestones)) } // get_milestone, update_milestone, delete_milestone … }
Step 6: Register routes in router.rs
Open crates/tack-api/src/router.rs. Add the import:
#![allow(unused)] fn main() { use crate::handlers::milestones; }
Then add the routes in the api router builder:
#![allow(unused)] fn main() { // ─── Milestones ─────────────────────────────────────────────────────────── .route("/projects/{project_id}/milestones", post(milestones::create_milestone)) .route("/projects/{project_id}/milestones", get(milestones::list_milestones)) .route("/milestones/{id}", get(milestones::get_milestone)) .route("/milestones/{id}", patch(milestones::update_milestone)) .route("/milestones/{id}", delete(milestones::delete_milestone)) }
At this point the feature is complete. Run cargo nextest run --workspace to verify nothing is broken, then add repository tests in crates/tack-db/tests/repository.rs and handler tests in crates/tack-api/tests/handlers.rs — each test file is its own binary, so add to the file whose subject fits rather than creating a new one (see Testing).
Adding a New Workflow Preset
Example: education_workflow for an online-course project type
Step 1: Add the preset function in tack-core
Open crates/tack-core/src/workflow.rs and add the function after the existing presets:
#![allow(unused)] fn main() { pub fn education_workflow() -> WorkflowConfig { WorkflowConfig { workflow_type: WorkflowType::Custom, statuses: vec![ StatusDef { name: "Not Started".into(), category: StatusCategory::Todo, wip_limit: None, order: 0, }, StatusDef { name: "In Progress".into(), category: StatusCategory::InProgress, wip_limit: None, order: 1, }, StatusDef { name: "Under Review".into(), category: StatusCategory::InProgress, wip_limit: None, order: 2, }, StatusDef { name: "Completed".into(), category: StatusCategory::Done, wip_limit: None, order: 3, }, ], transitions: None, } } }
Step 2: Write unit tests
Add tests in the #[cfg(test)] module in the same file:
#![allow(unused)] fn main() { #[test] fn education_initial_status_is_not_started() { assert_eq!(education_workflow().initial_status().unwrap(), "Not Started"); } #[test] fn education_allows_in_progress_to_completed() { let wf = education_workflow(); assert!(wf.validate_transition("In Progress", "Completed").is_ok()); } }
Unit testing presets here is valuable because these tests run without any database or runtime — they are essentially free to run and will catch any mistake in the status list.
Step 3: Add the ProjectType variant
Open crates/tack-core/src/models.rs and add Education to the ProjectType enum. The
enum is exhaustive — every match on it must add an arm — so this is the full current
list, not an excerpt:
#![allow(unused)] fn main() { pub enum ProjectType { Software, Web, Mobile, Construction, Personal, Homework, Maintenance, Legal, Research, Event, Education, // ← new Custom, } }
Also update the Display impl:
#![allow(unused)] fn main() { Self::Education => write!(f, "education"), }
Step 4: Update workflow_for_type
In workflow.rs, add the new arm to the match in workflow_for_type:
#![allow(unused)] fn main() { pub fn workflow_for_type(project_type: &ProjectType) -> WorkflowConfig { match project_type { ProjectType::Software | ProjectType::Web | ProjectType::Mobile => scrum_workflow(), ProjectType::Construction => construction_workflow(), ProjectType::Personal | ProjectType::Homework => simple_workflow(), ProjectType::Maintenance => kanban_workflow(), ProjectType::Legal => legal_workflow(), ProjectType::Research => research_workflow(), ProjectType::Event => event_workflow(), ProjectType::Education => education_workflow(), // ← new ProjectType::Custom => simple_workflow(), } } }
Step 5: Add a vocabulary preset
Open crates/tack-core/src/vocabulary.rs and add a case to vocabulary_for_type:
#![allow(unused)] fn main() { ProjectType::Education => HashMap::from([ ("epic".into(), "Course".into()), ("feature".into(), "Module".into()), ("task".into(), "Lesson".into()), ("subtask".into(), "Exercise".into()), ("bug".into(), "Correction".into()), ("sprint".into(), "Week".into()), ("milestone".into(), "Exam".into()), // … other terms ]), }
At this point POST /api/projects with "project_type": "education" will auto-select the new workflow and vocabulary.
Extending the Workflow Engine
Sometimes you need new logic in the workflow engine itself — for example, a rule that prevents moving an item to Done if any of its dependencies are still incomplete.
Step 1: Add the function to tack-core
Open crates/tack-core/src/workflow.rs. Add a pure function:
#![allow(unused)] fn main() { impl WorkflowConfig { /// Return an error if attempting to mark `item_id` done when it has /// unresolved blocking dependencies. pub fn check_dependencies_resolved( item_id: Uuid, blockers: &[(Uuid, DependencyType)], target_status: &str, ) -> Result<(), CoreError> { if self.is_done_status(target_status) && !blockers.is_empty() { return Err(CoreError::Validation(format!( "Item {item_id} has {} unresolved blocker(s)", blockers.len() ))); } Ok(()) } } }
Note that this function takes its inputs as parameters — it does not query the database. The caller (the handler) is responsible for loading the blocker list and passing it in.
Step 2: Write unit tests
Add tests in the #[cfg(test)] block in the same file. Test both the passing case (no blockers, or target not Done) and the failing case (blockers present, target is Done).
Unit tests for workflow logic are intentionally cheap to write here because there is no I/O to mock — you just call the function with constructed data.
Step 3: Update the handler
Open crates/tack-api/src/handlers/items.rs. In update_item, after loading the project and before calling repo.update_item, add the new check:
#![allow(unused)] fn main() { if let Some(new_status) = &input.status { let project = state.repo.get_project(item.project_id).await? ...; project.workflow.validate_transition(&item.status, new_status)?; project.workflow.check_wip_limit(new_status, current_count)?; // New check: load blockers and verify they are resolved let all_deps = state.repo.list_dependencies(item.id).await?; let graph = DependencyGraph::from_edges(&all_deps); let blockers = graph.blockers_of(item.id); project.workflow.check_dependencies_resolved(item.id, &blockers, new_status)?; } }
What not to change
No migration is needed. The workflow engine is pure logic — it reads from a WorkflowConfig struct that is already stored as JSON. Adding a new method to WorkflowConfig does not require any database schema change.
Anti-Patterns to Avoid
The crate layering is the project's load-bearing constraint. Most review feedback on new features comes down to one of these:
- Don't put I/O in
tack-core. No file access, no HTTP calls, nosqlx, notokioruntime needs. Core is pure, synchronous domain logic so it stays trivially testable. If a rule needs data, take it as a function parameter and let the handler load it (as in the dependency check above). - Don't scatter validation across handlers. Transition rules, WIP limits, cycle checks, and field validation belong in
tack-core, called from the handler. Duplicating a rule inline in a handler means the CLI, API, and MCP server can disagree about what's valid. - Don't reach past the repository layer. Handlers call
Repositorymethods; they never build SQL or touch the pool directly. New queries go in the matchingrepo/<entity>.rsmodule. - Don't let
tack-cliimporttack-db. The CLI is an HTTP client — all data access goes through the API so workflow rules are enforced server-side. The same applies to the MCP server. - Don't edit an existing migration. Migrations are append-only and idempotent. Changing a shipped migration corrupts databases that already applied it. Add a new numbered migration and wire it into
all_migrations(). - Don't hardcode colors in the frontend. Components consume
--color-*design tokens via inlinestyle, never raw hex, so the theme/palette system keeps working. See Frontend & Design System. - Don't add an endpoint without a handler test. Every new route gets at least a success-path and an error-path test in
crates/tack-api/tests/handlers.rs(or the file matching the route's subject — see Testing).
When a change feels like it needs to break one of these, that's usually a sign the logic belongs in a different layer — move it rather than bending the boundary.
If the new logic requires new configuration (for example, a per-project flag to enable or disable dependency-blocking), then you would add a field to WorkflowConfig, update the struct, and add a migration to handle existing rows that do not have that field (SQLite will use the column default).