kanban-domain
Pure domain logic — zero I/O, zero async, zero infrastructure dependencies. Depends only on kanban-core.
Models
Board
Top-level container for columns, cards, and sprints.
| Field | Type | Description |
|---|---|---|
id |
Uuid |
Unique identifier |
name |
String |
Display name |
description |
Option<String> |
Optional description |
card_prefix |
Option<String> |
Default prefix for card identifiers (e.g. "KAN" → KAN-1) |
sprint_prefix |
Option<String> |
Default prefix for sprint names |
sprint_names |
Vec<String> |
Pool of sprint name tokens (consumed in order) |
prefix_counters |
HashMap<String, u32> |
Per-prefix card number counter |
created_at |
DateTime<Utc> |
Creation timestamp |
updated_at |
DateTime<Utc> |
Last modification timestamp |
Key methods:
board.get_next_card_number // Atomically increments and returns the next card number for the given prefix.
board.resolve_completion_column // Returns the rightmost column, used as the "done" column when toggling completion.
board.consume_sprint_name // Pops and returns the next sprint name from sprint_names, if any.
Partial update:
Column
A swim lane within a board.
| Field | Type | Description |
|---|---|---|
id |
Uuid |
Unique identifier |
board_id |
Uuid |
Parent board |
name |
String |
Display name |
position |
i32 |
Display order (lower = left) |
wip_limit |
Option<i32> |
Advisory WIP limit — shown in the UI as a guide |
created_at |
DateTime<Utc> |
Creation timestamp |
updated_at |
DateTime<Utc> |
Last modification timestamp |
WIP limits are advisory: the UI surfaces it as a visual cue; card creation always succeeds.
Partial update: ColumnUpdate { name, position, wip_limit: FieldUpdate<i32> }
Card
The primary work item.
| Field | Type | Description |
|---|---|---|
id |
Uuid |
Unique identifier |
column_id |
Uuid |
Parent column |
title |
String |
Card title |
description |
Option<String> |
Long-form description (markdown supported) |
priority |
CardPriority |
Low / Medium / High / Critical |
status |
CardStatus |
Todo / InProgress / Blocked / Done |
position |
i32 |
Display order within the column |
due_date |
Option<DateTime<Utc>> |
Optional due date |
points |
Option<u8> |
Story points (1–5) |
card_number |
u32 |
Sequential number within the prefix namespace |
sprint_id |
Option<Uuid> |
Assigned sprint, if any |
assigned_prefix |
Option<String> |
Prefix used when the card was created (e.g. "KAN") |
card_prefix |
Option<String> |
Optional per-card prefix override |
completed_at |
Option<DateTime<Utc>> |
Set when status transitions to Done, cleared otherwise |
sprint_logs |
Vec<SprintLog> |
History of sprint assignments |
created_at |
DateTime<Utc> |
Creation timestamp |
updated_at |
DateTime<Utc> |
Last modification timestamp |
Status transitions: card.update_status(status) — automatically sets completed_at = Some(now) when transitioning to Done, and clears it when transitioning away from Done.
Branch name generation: derived from assigned_prefix + card_number + slugified title (lowercase, hyphens replace whitespace and punctuation, truncated for length).
Partial update: CardUpdate { title, description, priority, status, position, column_id, points, due_date, sprint_id, assigned_prefix, card_prefix } — all fields are Option<T> or FieldUpdate<T>.
Sprint
A time-boxed work period.
| Field | Type | Description |
|---|---|---|
id |
Uuid |
Unique identifier |
board_id |
Uuid |
Parent board |
sprint_number |
u32 |
Sequential sprint number within the board |
name_index |
Option<usize> |
Index into board.sprint_names for the human-readable name |
prefix |
Option<String> |
Sprint prefix override (falls back to board prefix) |
card_prefix |
Option<String> |
Card prefix override for this sprint |
status |
SprintStatus |
Planning / Active / Completed / Cancelled |
start_date |
Option<DateTime<Utc>> |
Set when activated |
end_date |
Option<DateTime<Utc>> |
Set when activated (= start + duration) |
created_at |
DateTime<Utc> |
Creation timestamp |
updated_at |
DateTime<Utc> |
Last modification timestamp |
Status lifecycle:
Planning ──activate()──► Active ──complete()──► Completed
└──cancel()───► Cancelled
Key methods:
sprint.activate
// Sets status to Active, records start_date = now, end_date = now + duration.
sprint.formatted_name // e.g. "KAN-3/bugfix-week" or "KAN-3"
sprint.is_ended // True if Active and end_date is before `now`.
for_assignment_dialog // Splits a board's sprints into (active_or_planned, completed_or_ended).
// Cancelled sprints are excluded; each section is sorted by sprint_number desc.
SprintLog
Records a single sprint assignment event for a card.
| Field | Type | Description |
|---|---|---|
sprint_id |
Uuid |
Sprint that was assigned |
assigned_at |
DateTime<Utc> |
When the assignment occurred |
unassigned_at |
Option<DateTime<Utc>> |
When the assignment ended, if applicable |
A card accumulates one SprintLog entry per unique sprint assignment. Re-assigning to the same sprint is a no-op (deduplication by sprint_id).
ArchivedCard
A card that has been moved out of active columns.
| Field | Type | Description |
|---|---|---|
id |
Uuid |
Unique identifier |
original_column_id |
Uuid |
Column the card was in before archiving |
original_position |
i32 |
Position before archiving |
card |
Card |
Full card snapshot |
archived_at |
DateTime<Utc> |
When the card was archived |
Restoring an archived card places it back in original_column_id at original_position (or a specified column if provided).
FieldUpdate<T>
Three-state enum for partial updates to optional fields.
// Apply to a mutable target:
update.apply_to;
Used throughout all *Update structs to distinguish "not provided" from "explicitly set to None".
DependencyGraph
Container for all card-relation edges, stored alongside the board snapshot. Three discrete sub-graphs, each with its own structural rules and its own concrete edge kind (carrying any per-kind metadata):
| Sub-graph | Type | Cycles | Per-kind metadata |
|---|---|---|---|
parent_child |
DagGraph<SpawnsEdge> |
rejected | none today |
blocks |
DagGraph<BlocksEdge> |
rejected | Severity |
relates |
UndirectedGraph<RelatesEdge> |
permitted | RelatesKind |
Each per-kind edge struct embeds the shared EdgeBase (endpoints, timestamps, archival state) via #[serde(flatten)] and adds its own metadata:
All three implement the Edge trait from kanban-core::graph, so they plug into the generic DagGraph / UndirectedGraph machinery. The flatten plus default combination means edges written before the metadata fields existed deserialise cleanly into the new shape.
Severity (on BlocksEdge) — variant order matches conventional escalation, so derived Ord reads naturally:
RelatesKind (on RelatesEdge):
Invariants: each sub-graph independently rejects self-references and duplicate edges; the two DAG sub-graphs also reject edges that would close a cycle. UndirectedGraph<RelatesEdge> matches duplicates in either orientation.
Cross-cutting cascades: archive_node, unarchive_node, and remove_node fan out across all three sub-graphs. Read-only aggregates (len, is_empty, active_len, contains, contains_archived) sum across them.
Cross-kind discriminator: CardEdgeType { Blocks, RelatesTo, Spawns } exists only for cross-kind tooling (parameterised tests, debugging utilities, requires_dag / allows_cycles checks). Production code paths are per-kind: per-kind edges, per-kind sub-graphs, per-kind GraphOperations verbs, per-kind DependencyCommand variants. The enum is never used as a runtime discriminator on edges themselves.
GraphOperations
Service-layer interface to the card-relation graph. One canonical method per per-kind operation, with plural batch primitives as the unit of atomicity. The singular variants are default methods that delegate to the plural by wrapping a single id in a Vec, which routes every mutation through the same transactional path.
Per-kind methods carry per-kind metadata directly in their signatures — severity for blocks, kind for relates, nothing extra for spawns. No runtime kind discriminator.
Design notes:
- The trait stands alone from the
KanbanOperationsgod-trait — no supertrait bound. Implementers compose both separately when they need card-resolution alongside graph mutation. - List queries return
Vec<Uuid>. Surfaces that need display data resolve ids at their own boundary. - Cross-board parent/child is permitted at the domain layer today; board-scoping is a separate decision left to the caller.
DependencyCommand
Per-kind dependency commands routed through the command bus. Each variant has a single relation kind baked into its type and carries the kind-specific metadata directly. Replay sees the same metadata the forward saw:
CreateSubcard is atomic create-card-and-link-as-subcard — genuinely different from the edge commands because it touches the board (card counter), the card store (new card), and the graph (parent edge) in one step. Its inverse is DeleteCard, which is polymorphic over live / archived and strips incident edges in the same pass.
The previously kind-agnostic RemoveDependencyCommand was removed; each Add* now captures a per-kind Remove* inverse with tolerate_missing = true, so a [AddSpawns(a,b), AddBlocks(a,b)] batch undoes each kind independently instead of having the first inverse wipe both.
Both Add* and Remove* carry per-paradigm flags with #[serde(default)] so legacy command-log entries replay unchanged:
tolerate_missing: boolonRemove*— swallowsEdgeNotFoundduring inverse replay so undo succeeds against an already-removed edge. User-initiated paths set thisfalse(strict); inverse-capture sets ittrue.as_archived: boolonAdd*— inserts the edge already in the archived state. Used by cascade-undo (DeleteCard/DeleteCardEdges) to preserve the active/archived split across delete/undo cycles. User-initiated paths leave thisfalse(edges land active);edges_to_undo_commandssets it from!e.is_active()per restored edge so archived incident edges restore as archived instead of silently reviving to active.
HistoryManager
Undo/redo stack for KanbanContext.
| Method | Description |
|---|---|
capture_before_command(snapshot) |
Push snapshot onto undo stack |
pop_undo() |
Pop and return the most recent undo snapshot |
push_redo(snapshot) |
Push snapshot onto redo stack |
suppress() |
Temporarily disable capture (used during undo/redo) |
clear() |
Clear both stacks (called on external reload) |
Both stacks are capped at 100 entries. The oldest entries are dropped when the cap is exceeded.
Error Types
KanbanError
DomainError
DependencyError
Helper constructors on KanbanError:
not_found
validation
serialization
is_not_found
Business Rules
- Card numbering: per-prefix counter stored in
Board::prefix_counters; monotonically increasing, permanently unique per prefix - Sprint assignment deduplication: assigning a card to a sprint it already belongs to is a no-op
- Completion column: the rightmost column is used when toggling a card to Done (falls back to the rightmost column)
- WIP limits: advisory only — enforcement is the UI's responsibility
- Sprint assignability: only
PlanningandActivesprints can be assigned to cards
Dependencies
| Crate | Purpose |
|---|---|
kanban-core |
Error types, config, graph |
serde + serde_json |
Serialization |
uuid |
Uuid type |
chrono |
Timestamps |
thiserror |
Error derivation |