Technical Design TD-0005 — Issue Tracking Workflows and Boards¶
| Field | Value |
|---|---|
| ID | TD-0005 |
| Title | Issue Tracking Workflows and Boards |
| Status | Draft |
| Owner | Loryth Team |
| Audience | contributors |
| Created | 2026-02-17 |
| Last Updated | 2026-02-17 |
| Reviewers | — |
| Approvers | — |
| Tracking | — |
| Related | ADR-0016, TDD-0004 |
1. Summary¶
- The issue tracking service needs a workflow engine with auto-walk transitions and a board system supporting Kanban and Scrum methodologies
- This document specifies the workflow transition model, auto-walk algorithm, board entities, LexoRank ordering strategy, and sprint lifecycle
- Impact: enables visual project management with drag-and-drop boards and sprint planning
2. Context and Problem Statement¶
The existing workflow_transition table models a directed graph of allowed status transitions. However, there is no board system to visualize issues by status, no sprint management, no drag-and-drop ranking, and no mechanism to handle status changes when board columns skip intermediate workflow steps.
Users expect to drag issues across board columns and have the system determine the correct status transition automatically, even when intermediate statuses exist in the workflow graph.
2.1 Goals¶
- Define the auto-walk transition algorithm for board drag operations
- Specify the board data model with columns, status mappings, and swimlanes
- Design the LexoRank ordering strategy with rebalancing
- Specify the sprint lifecycle model
- Define all related API endpoints
2.2 Out of Scope¶
- Workflow conditions or guards (future: conditional transitions based on field values)
- Board filters and JQL-like query language (future)
- Cross-project boards (future)
- Burndown charts (covered in TDD-0007 reporting)
2.3 Assumptions and Constraints¶
- Workflows are small directed graphs (typically fewer than 20 statuses)
- A single issue has one global rank (no per-board or per-sprint ranking)
- At most one sprint can be active per project at any time
- Board operations are synchronous (no background processing except rank rebalancing)
3. Design Overview¶
3.1 Key Components¶
| Component | Purpose |
|---|---|
| Workflow engine | Transition validation and auto-walk BFS pathfinding |
| Board model | Visual representation with columns, status mappings, and swimlanes |
| LexoRank system | Lexicographic string ranking for drag-and-drop ordering |
| Sprint model | Time-boxed iterations with planned/active/completed lifecycle |
3.2 Architecture¶
flowchart LR
subgraph Board["Board System"]
BC[Board Column] --> BSM[Column-Status Mapping]
BC --> PS[Primary Drop Status]
BC --> SL[Swimlane Config]
end
subgraph Workflow["Workflow Engine"]
WT[Workflow Transitions] --> AW[Auto-Walk BFS]
AW --> TV[Transition Validation]
end
subgraph Ranking["Ranking System"]
LR_[LexoRank] --> RB[Rebalance Job]
end
subgraph Sprint["Sprint Management"]
SP[Sprint] --> BL[Backlog]
SP --> AS[Active Sprint]
end
Board --> Workflow
Board --> Ranking
Board --> Sprint 3.3 Interface Impact (Before → After)¶
| Area | Before | After |
|---|---|---|
| Workflow validation | can_transition() for direct edges only | Auto-walk finds paths through intermediate statuses |
| Issue ordering | No ordering support | LexoRank column on Issue for global ordering |
| Issue views | List view only | Board view with columns and swimlanes |
| Sprint tracking | Not supported | Sprint entity with lifecycle management |
4. Implementation Details¶
4.1 Workflow Transition Model¶
The existing workflow_transition table already models the directed graph:
workflow_transition(workflow_id, source_status_id, target_status_id)
Two key operations:
- Direct transition check:
can_transition(workflow_id, from_status, to_status)— single edge lookup - Path finding:
find_path(workflow_id, from_status, to_status)— BFS for auto-walk
4.2 Auto-Walk Algorithm¶
When a user drags an issue to a board column, the system determines the target status (the column's primary_status_id) and attempts to transition the issue. If no direct transition exists, BFS finds the shortest valid path.
from collections import deque
def find_path(
transitions: dict[int, list[int]],
from_status_id: int,
to_status_id: int,
) -> list[int] | None:
"""Find shortest path through workflow transition graph using BFS.
:param transitions: Adjacency list of workflow transitions (status_id → [target_status_ids])
:param from_status_id: Current status ID
:param to_status_id: Target status ID
:return: List of status IDs from current (exclusive) to target (inclusive), or None if no path
"""
if from_status_id == to_status_id:
return []
queue: deque[tuple[int, list[int]]] = deque([(from_status_id, [])])
visited: set[int] = {from_status_id}
while queue:
current, path = queue.popleft()
for neighbor in transitions.get(current, []):
if neighbor == to_status_id:
return path + [neighbor]
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return None
Board drag behavior:
- User drags issue to column C
- Resolve target status =
C.primary_status_id - Load workflow transitions for the issue's type/project
- Call
find_path(transitions, issue.status_id, target_status_id) - If path is
None→ reject with422 Unprocessable Entity - If path exists → set
issue.status_id = target_status_id - Record one activity log entry:
status_changed(from=original, to=target) - If target status has
category = 'in_progress'andissue.start_date IS NULL→ setissue.start_date = NOW()
4.3 Board Data Model¶
Board¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
project_id | BIGINT | FK → project(id), NOT NULL | |
name | VARCHAR(100) | NOT NULL | |
board_type | board_type | NOT NULL | Enum: kanban, scrum |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
BoardColumn¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
board_id | BIGINT | FK → board(id) ON DELETE CASCADE, NOT NULL | |
name | VARCHAR(100) | NOT NULL | |
position | INTEGER | NOT NULL | Left-to-right order |
primary_status_id | BIGINT | FK → status(id), NOT NULL | Status set on drop |
wip_limit | INTEGER | Optional work-in-progress limit | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraint: UNIQUE(board_id, position).
BoardColumnStatus¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
board_column_id | BIGINT | FK → board_column(id) ON DELETE CASCADE, NOT NULL | |
status_id | BIGINT | FK → status(id), NOT NULL |
To enforce "each status in at most one column per board", a unique index is needed. Since board_id is not directly on this table, enforcement uses a compound approach:
-- Partial unique index via a function or materialized view
-- Simpler approach: application-level validation on column configuration
CREATE UNIQUE INDEX uq_board_column_status
ON issues.board_column_status (board_column_id, status_id);
Application-level validation ensures a status does not appear in multiple columns of the same board.
BoardSwimlaneConfig¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
board_id | BIGINT | FK → board(id) ON DELETE CASCADE, UNIQUE, NOT NULL | One config per board |
swimlane_type | swimlane_type | NOT NULL, DEFAULT 'none' | Enum: none, assignee, priority, issue_type |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
4.4 LexoRank Ordering¶
Alphabet and Encoding¶
- Character set:
0123456789abcdefghijklmnopqrstuvwxyz(36 characters) - Midpoint character:
i(index 18 of 36) - Ranks are compared lexicographically using standard string comparison
Operations¶
Initial rank (first issue in project):
rank = "n" # midpoint of alphabet
Insert between two issues:
def generate_between(rank_a: str, rank_b: str) -> str:
"""Generate a rank string between rank_a and rank_b.
:param rank_a: Lower bound rank (exclusive)
:param rank_b: Upper bound rank (exclusive)
:return: New rank string that sorts between rank_a and rank_b
"""
# Pad shorter string with '0' (lowest char) for comparison
max_len = max(len(rank_a), len(rank_b))
a = rank_a.ljust(max_len, '0')
b = rank_b.ljust(max_len, '0')
# Find first differing position
for i in range(max_len):
if a[i] != b[i]:
mid_char = ALPHABET[(ALPHABET.index(a[i]) + ALPHABET.index(b[i])) // 2]
if mid_char != a[i]:
return a[:i] + mid_char
# Adjacent characters — extend with midpoint
return a[:i + 1] + 'i'
# Identical up to max_len — extend rank_a with midpoint
return rank_a + 'i'
Insert at top (before minimum rank):
If min_rank[0] > '0': new_rank = ALPHABET[ALPHABET.index(min_rank[0]) // 2]
If min_rank[0] == '0': new_rank = '0' + 'i'
Insert at bottom (after maximum rank):
If max_rank[0] < 'z': new_rank = ALPHABET[(ALPHABET.index(max_rank[0]) + 35) // 2 + 1]
If max_rank[0] == 'z': new_rank = 'z' + 'i'
Rebalancing¶
When any rank in a project exceeds 50 characters:
- Acquire advisory lock:
pg_advisory_xact_lock(project_id + REBALANCE_LOCK_OFFSET) - Load all issues in the project ordered by current rank
- Assign evenly-spaced ranks using the alphabet
- Update all issue ranks in a single transaction
- Release lock (automatic on transaction commit)
Rebalancing is triggered as a background task after any rank operation produces a string exceeding the threshold.
4.5 Sprint Model¶
Sprint Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
project_id | BIGINT | FK → project(id), NOT NULL | |
name | VARCHAR(100) | NOT NULL | |
goal | TEXT | Optional sprint goal | |
start_date | DATE | Set when sprint starts | |
end_date | DATE | Planned end date | |
status | sprint_status | NOT NULL, DEFAULT 'planned' | Enum: planned, active, completed |
completed_at | TIMESTAMPTZ | Set when sprint completes | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Sprint Lifecycle¶
stateDiagram-v2
[*] --> Planned: Create sprint
Planned --> Active: Start sprint
Active --> Completed: Complete sprint
Completed --> [*] Constraints: - At most one active sprint per project: UNIQUE(project_id) WHERE status = 'active' (partial unique index) - Starting a sprint sets start_date = TODAY if not already set - Completing a sprint sets completed_at = NOW()
Sprint completion workflow: 1. All issues with status.category = 'done' remain in the completed sprint 2. Incomplete issues (status category todo or in_progress) are moved to the next planned sprint or set to sprint_id = NULL (backlog) if no planned sprint exists 3. The choice is made by the user at completion time via request parameter
Backlog: Issues with sprint_id IS NULL and deleted_at IS NULL. Displayed in rank order.
4.6 start_date Auto-Set¶
When an issue transitions to a status with category = 'in_progress': - If issue.start_date IS NULL → set issue.start_date = CURRENT_DATE - If issue.start_date is already set → no change (preserves manual overrides)
This applies to all status transitions, whether from direct API calls, board drags, or auto-walk.
4.7 API Endpoints¶
Board Endpoints¶
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /projects/{key}/boards | List boards for project | SuccessResponse[list[BoardModel]] |
| POST | /projects/{key}/boards | Create board | SuccessResponse[BoardModel] — 201 |
| GET | /boards/{uuid} | Get board with columns and issue counts | SuccessResponse[BoardDetailModel] |
| PUT | /boards/{uuid} | Update board (name, type, swimlane) | SuccessResponse[BoardModel] |
| DELETE | /boards/{uuid} | Delete board | 204 No Content |
| POST | /boards/{uuid}/columns | Add column to board | SuccessResponse[BoardColumnModel] — 201 |
| PUT | /boards/{uuid}/columns/{uuid} | Update column config | SuccessResponse[BoardColumnModel] |
| DELETE | /boards/{uuid}/columns/{uuid} | Remove column | 204 No Content |
Issue Movement Endpoints¶
| Method | Path | Description | Response |
|---|---|---|---|
| PUT | /issues/{key}/rank | Reorder issue | SuccessResponse[IssueModel] |
| PUT | /issues/{key}/move | Move to board column (auto-walk) | SuccessResponse[IssueModel] |
Rank request body:
{
"before": "PROJ-45",
"after": "PROJ-46"
}
At least one of before or after must be provided. Both null is invalid. If only before is provided, the issue is placed at the top. If only after is provided, the issue is placed at the bottom.
Move request body:
{
"board_uuid": "...",
"column_uuid": "..."
}
Sprint Endpoints¶
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /projects/{key}/sprints | List sprints (paginated) | SuccessResponse[PageModel[SprintModel]] |
| POST | /projects/{key}/sprints | Create sprint | SuccessResponse[SprintModel] — 201 |
| GET | /sprints/{uuid} | Get sprint details | SuccessResponse[SprintDetailModel] |
| PUT | /sprints/{uuid} | Update sprint (name, goal, dates) | SuccessResponse[SprintModel] |
| PUT | /sprints/{uuid}/start | Start sprint | SuccessResponse[SprintModel] |
| PUT | /sprints/{uuid}/complete | Complete sprint | SuccessResponse[SprintModel] |
Complete sprint request body:
{
"move_to": "next_sprint | backlog"
}
4.8 Error Handling and Edge Cases¶
| Scenario | Response |
|---|---|
| No valid transition path for board move | 422 Unprocessable Entity with message explaining which statuses lack a path |
| WIP limit exceeded on target column | 422 Unprocessable Entity (advisory — configurable to warn or block) |
| Starting sprint when another is active | 409 Conflict |
Completing sprint with no planned successor and move_to=next_sprint | 422 Unprocessable Entity |
| Rank string exceeds threshold | Operation succeeds; rebalancing triggered asynchronously |
| Deleting a column with mapped issues | Column removed; issues remain in their current status (visible in backlog) |
5. Semantics¶
- Board columns define a view of issues, not ownership. Deleting a column does not affect issues.
- An issue's rank is global across all views (board, backlog, sprint). Moving an issue on a board does not change its rank unless explicitly reordered.
- Auto-walk is transparent to the user — they see a single status change, not intermediate steps.
- Sprint assignment (
sprint_id) is independent of board display. An issue can be in a sprint but not on any board. - The
start_dateauto-set only fires once (first transition to "in progress"). Subsequent transitions between "in progress" statuses do not reset it.
6. Examples¶
6.1 Board Drag with Auto-Walk¶
Workflow transitions: Open → In Progress → Code Review → QA → Done
Board columns: [To Do (Open)] [In Progress (In Progress)] [Review (Code Review, QA)] [Done (Done)]
primary: Code Review
Issue is in "Open" status. User drags to "Review" column.
1. Target status = Code Review (primary_status of Review column)
2. find_path(Open, Code Review) = [In Progress, Code Review]
3. Path exists → set issue.status_id = Code Review
4. Activity log: status_changed(from="Open", to="Code Review")
5. start_date auto-set: Code Review has category='in_progress' → set start_date=TODAY
6.2 Sprint Completion¶
Active sprint "Sprint 5" has 10 issues:
- 6 with status.category = 'done'
- 4 with status.category = 'in_progress' or 'todo'
User completes Sprint 5 with move_to="next_sprint":
1. Sprint 5 status → completed, completed_at = NOW()
2. Find next planned sprint ("Sprint 6")
3. Move 4 incomplete issues: sprint_id = Sprint 6.id
4. 6 completed issues remain: sprint_id = Sprint 5.id (for velocity tracking)
7. Test Matrix¶
7.1 Unit Coverage¶
- BFS pathfinding: direct transition, multi-hop path, no path, self-loop, disconnected graph
- LexoRank generation: between two ranks, at top, at bottom, adjacent characters, long strings
- Sprint lifecycle: valid transitions, invalid transitions (e.g., planned → completed)
7.2 Integration Coverage¶
- Board drag with auto-walk (direct and multi-hop)
- Board drag rejection when no path exists
- Sprint start/complete lifecycle with issue movement
- Rank rebalancing triggered by long rank strings
- start_date auto-set on "in progress" transition
- WIP limit enforcement on board columns
- Concurrent rank updates on same project
7.3 Regression Coverage¶
- Existing
can_transition()behavior unchanged - Direct status change via API (non-board) still validates transitions
- Workflow CRUD operations unaffected
8. Acceptance Criteria¶
- Auto-walk finds shortest path through workflow graph via BFS
- Board drag sets the column's primary status on the issue
- Auto-walk records one activity log entry (not intermediate statuses)
- Invalid transitions (no path) return 422
- LexoRank generates valid between-ranks for all edge cases
- Rank rebalancing normalizes all ranks when threshold exceeded
- At most one active sprint per project (enforced by partial unique index)
- Sprint completion moves incomplete issues per user's choice
- start_date auto-set on first "in progress" transition only
9. Rollout and Migration¶
9.1 Phased Plan¶
Phase 2a: Schema - Create enum types (board_type, swimlane_type, sprint_status) - Create tables: board, board_column, board_column_status, board_swimlane_config, sprint - Add sprint_id FK to issue table (if not already added in Phase 1 migration)
Phase 2b: Workflow engine - Implement find_path() BFS algorithm - Integrate auto-walk into status transition flow - Add start_date auto-set logic
Phase 2c: Board and sprint - Implement board CRUD and column management - Implement LexoRank generation and rebalancing - Implement sprint lifecycle - Build all API endpoints
9.2 Migration Strategy¶
New tables are additive — no existing data is modified. The sprint_id column on issue defaults to NULL (backlog). Boards are created by users after migration; no default boards are auto-generated.
10. Risks and Mitigations¶
| Risk | Mitigation |
|---|---|
| BFS on large workflow graphs | Workflows are bounded (< 20 statuses typically); BFS is O(V+E), negligible cost |
| LexoRank strings growing without bound | Rebalancing threshold at 50 chars; background job reassigns evenly-spaced ranks |
| Concurrent rank operations producing duplicates | Advisory lock per project during rebalancing; application-level midpoint calculation is deterministic |
| Sprint partial unique index compatibility | PostgreSQL supports partial unique indexes natively; tested on 17.4 |
11. Progress¶
- Implement auto-walk BFS algorithm
- Create board entity and repository
- Implement board column management
- Implement LexoRank service
- Implement rank rebalancing job
- Create sprint entity and repository
- Implement sprint lifecycle service
- Build board API endpoints
- Build sprint API endpoints
- Write integration tests
12. Changelog¶
| Date | Change |
|---|---|
| 2026-02-17 | Initial version |
13. Glossary¶
| Term | Definition |
|---|---|
| Auto-walk | Automatic traversal of intermediate workflow statuses when a direct transition doesn't exist. Uses BFS to find the shortest valid path. |
| Board column | A visual grouping on a board that displays issues in one or more statuses. |
| Primary drop status | The status assigned to an issue when it is dragged into a board column. Must be one of the column's mapped statuses. |
| LexoRank | A lexicographic string ranking system that enables O(1) insert/move operations by computing midpoint strings. |
| Swimlane | A horizontal grouping on a board, dividing issues by a dimension (assignee, priority, or type). |
| WIP limit | Work-in-progress limit — the maximum number of issues allowed in a board column. Advisory or enforced. |
| Backlog | Issues with no sprint assignment (sprint_id IS NULL). Displayed in rank order. |