Skip to content

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:

  1. Navigate to /projects.
  2. Click New project.
  3. Fill in key and name. description is optional and accepts markdown.
  4. 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 sets closed_at.
  • A position (integer) — drives left-to-right column order.
  • An is_default flag — 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 activedone updates closed_at on 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:

  1. closed_at maintenance. A ticket entering a done-category state gets closed_at = now(). A ticket leaving a done state clears it. This is how "is this done?" queries stay cheap.
  2. 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 return 404 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.