Projects#
A project is the container that owns tickets, workflow states, and labels. Wiki pages and sticky notes can either belong to a project or live at the workspace (global) scope.
In Ticket Board, projects are the unit you delegate to — when you scope a bot, you scope it to one or more project ids. Configure the project well at the start and the rest of the system inherits the shape.
Anatomy of a project#
| Field | Meaning | Mutable later? |
|---|---|---|
key |
Short identifier (e.g. PROJ, INFRA). Drives ticket display ids like PROJ-123. ^[A-Z][A-Z0-9]{1,9}$. |
No — the key is permanent. |
name |
Display name shown in the UI. | Yes |
description |
Markdown blurb shown on the project home. | Yes |
owner |
The human user who owns the project. | Yes |
| Workflow states | An ordered list of states a ticket can occupy (e.g. "To Do", "In Progress", "Done"). | Yes — add, rename, recategorize, reorder, delete. |
| Labels | Per-project tags with a color, attached to tickets. | Yes |
next_ticket_number |
Internal counter; you never set this directly. | No — server-owned. |
The key is the one decision that does not unwind. Pick something short, all-caps, that you'd be happy to type a thousand times. TKBD, INFRA, OPS, HOME are all fine; PROJECT-ALPHA-2026-Q3 is not.
Creating a project#
From the UI:
- Navigate to
/projects. - Click New project.
- Fill in
keyandname.descriptionis optional and accepts markdown. - Submit. You land on the new project's home page.
Behind the scenes the server seeds a default workflow:
| Position | Name | Category | Default? |
|---|---|---|---|
| 0 | To Do | backlog |
yes |
| 1 | In Progress | active |
|
| 2 | Done | done |
You can edit this immediately on the Settings → Workflow tab; you don't have to live with the defaults.
Via the API (or a bot)#
curl -s -X POST http://localhost/api/v1/projects \
-H "Content-Type: application/json" \
-H "X-Requested-By: web" \
-b "tb_session=$YOUR_SESSION" \
-d '{"key":"INFRA","name":"Homelab infrastructure"}'
Only humans may create projects. Bots calling this endpoint receive 403 auth.forbidden (details.reason = "humans_only").
Pick the key first
The key is the ticket display id prefix and is immutable. If you ever rename a project later, the key stays — tickets keep their INFRA-42 identity even if the project becomes "Homelab Infrastructure (2026)".
Workflow states#
A workflow state is one column on the board. Each state has:
- A
name(free-form, displayed on the column header). - A
category∈{backlog, active, done}— drives the column's color band and determines whether a ticket entering this state setsclosed_at. - A
position(integer) — drives left-to-right column order. - An
is_defaultflag — exactly one state per project carries this; it's the state new tickets start in.
The data model is in spec/01_data_model.md#workflow_state. Surfaced behavior:
- Renaming a state is non-disruptive. Tickets in that state are unaffected.
- Recategorizing a state from
active→doneupdatesclosed_aton every ticket currently in that state at the moment of the change — but only as those tickets are next read/written, not eagerly. This is rarely interesting unless you're rebuilding reports. - Reordering happens via a single atomic "renumber the whole list" call (
POST /projects/{id}/workflow-states/reorder). The UI handles this for you; the API expects the full ordered id list. - Deleting a state is rejected if any non-deleted ticket is currently in it. You'll get
409 resource.conflict(reason=state_in_use). Move the tickets first, then delete the state.
Why the three categories matter#
The category is the discriminator the system uses for two things:
closed_atmaintenance. A ticket entering adone-category state getsclosed_at = now(). A ticket leaving adonestate clears it. This is how "is this done?" queries stay cheap.- Board grouping. The UI bands columns by category — backlog states in one color, active in another, done in a third. Same for the activity feed's "closed N tickets this week."
You can have many active columns (e.g. "In Progress", "In Review", "Blocked") and many done columns (e.g. "Done", "Shipped"). What matters is the category, not the name.
Why reorder is a single atomic call#
position is an integer with a unique constraint per project. Moving "In Review" between "In Progress" and "Done" requires renumbering several rows. The API does this in one transaction so the board view never observes a half-renumbered state. You don't write this code; the UI handles it. If you script it, post the full ordered list of state ids to /projects/{id}/workflow-states/reorder.
Labels#
Labels are per-project tags with a name and a CSS hex color (#RRGGBB). Tickets can carry zero or many.
| Action | Endpoint |
|---|---|
| List | GET /projects/{id}/labels |
| Create | POST /projects/{id}/labels |
| Update | PATCH /projects/{id}/labels/{label_id} |
| Delete | DELETE /projects/{id}/labels/{label_id} |
Deleting a label hard-removes it from every ticket it was attached to. There is no soft-delete on labels; this is intentional — labels are throwaway. If you want history, use a wiki page.
The color value is enforced by the API as ^#[0-9A-Fa-f]{6}$. The UI's swatch picker enforces this for you; if you're scripting, validate before sending.
Archiving and deletion#
There is no separate "archive" surface — soft-delete is the archive. Mechanics:
| Action | Effect |
|---|---|
Soft-delete (DELETE /projects/{id}) |
Sets deleted_at. The project disappears from /projects for everyone. Tickets, wiki pages, and sticky notes that belong to it are not cascaded — they remain in the database and become unsurfaced because the project home that would render them is gone. The data is still reachable via direct ids (e.g. GET /api/v1/projects/{id}/sticky-notes with the project UUID), and ticket / wiki rows accept ?include_deleted=true for humans. |
Include deleted (?include_deleted=true) |
Humans can browse soft-deleted projects via this query flag on list endpoints. Bots cannot — they receive 403 auth.forbidden. |
| Restore | Not exposed in the UI or REST. CLI only — see below. |
| Hard purge | Not in v1. Soft-delete is a one-way door from the UI's perspective. |
Restoring a soft-deleted project#
There is no POST /projects/{id}/restore. Restoration is a CLI operation, intentional so you don't accidentally undelete via a hot UI button.
docker compose exec backend python -c "
import asyncio
from sqlalchemy import update
from ticket_board.db.session import async_sessionmaker, engine
from ticket_board.db.models.project import Project
async def restore(key: str) -> None:
async with async_sessionmaker() as session:
await session.execute(
update(Project).where(Project.key == key).values(deleted_at=None)
)
await session.commit()
asyncio.run(restore('INFRA'))
"
The audit event for this manual restore lands as actor_kind='system'. If you want a proper attributed restore, do the same operation against the API with ?include_deleted=true to read it back into shape, but you'll still need a direct SQL update for the field flip — there is no exposed restore endpoint by design.
Deleting a project does not free its key
The unique constraint on (workspace_id, key) WHERE deleted_at IS NULL means a soft-deleted project's key becomes available again. If you create a new project with the same key, its tickets will start at 1 and ids will collide with the old project's tickets when viewed in audit history. Treat key reuse as a code smell; pick a fresh key instead.
Project settings UI#
The Settings tab on a project home page exposes:
- General: name, description, owner.
- Workflow: the workflow state list with drag-to-reorder and inline rename. The default state is marked with a star; click another state's star to change which one is default.
- Labels: the label palette with add/edit/delete.
- Archive: the soft-delete button. Lives at the bottom of the page with confirmation; pairs with the restore caveat above.
These map to the endpoints documented in spec/02_api_design.md#projects.
Bot access to projects#
A project means nothing to a bot until you add the project's id to that bot's project_allowlist. See Bots for the mechanics.
Rules of thumb:
- A bot with an empty allowlist (
[]) sees no projects. Listing endpoints return empty results; single-project reads return404 resource.not_found(we do not distinguish "doesn't exist" from "not visible to you"). - Adding a project to the allowlist is a
PATCH /bots/{id}operation — see Bots → Editing. - Removing a project from the allowlist is immediate. Any in-flight tool call referencing that project fails with
auth.forbidden(reason=project_not_in_allowlist).
Common questions#
Can I rename a project's key? No. The key participates in the ticket display id and the URL. The system explicitly forbids it via PATCH /projects/{id} (the field is excluded from the update payload).
Can two projects share a key? Only if one is soft-deleted. The unique constraint is partial: UNIQUE (workspace_id, key) WHERE deleted_at IS NULL. See the warning above about why you shouldn't lean on this.
What happens to tickets when I soft-delete a project? They stay in the database. They are not deleted. They become invisible because the UI filters them out via the project's deleted_at. If you ever restore the project (CLI), the tickets come back exactly as they were.
How do I see the audit trail of a project? Filter /audit by project_id. Project-level events use event_type='project.created' | 'project.updated' | 'project.deleted'; configuration events (workflow_state.*, label.*) carry project_id too. See Audit log.
Next#
- Walk through Tickets & boards — the surface you'll spend the most time in.
- If you're delegating to a bot, configure the project allowlist on Bots.