Feature #1543
closedFeature #1511: Replace ticket view with Kanban board for CTO and assigned developers
API: per-viewer visibility matrix and per-card drag capability
0%
docs/superpowers/specs/2026-08-03-kanban-board-design.md
Description
**Surface:** backend (`api/`)
**Depends on:** "API: assignee, priority and parent on the task list payload"
**Blocks:** "Frontend: Kanban board replaces the ticketing list view"
Authorization work, split out from the payload change on purpose: a mistake here leaks one developer's tickets to another, or one tenant's to another. Separate review, separate test suite.
Apply the PO's visibility matrix (decision **D2**) in `ListProjectTasks` (`application/task_board.py:86`), and attach each card's transition capability **in one pass over the list** — computed by the same progression rules that back the generic transition route and MCP `transition_ticket`, so the three can never disagree.
### The visibility matrix
| Viewer | Cards visible | Drag |
|---|---|---|
| Ordinary client (`internal_role: null`) | **all** tickets in their project | none — read-only board |
| Developer (`internal_role: "developer"`) | **only** tickets assigned to them | their single legal forward step |
| CTO (`internal_role: "cto"`) / admin (`is_admin`) | all tickets | full server-allowed set, backward included |
**Deliberate and not a bug: a developer sees fewer cards than the client does.** The client is the payer and owns the whole project view; a developer's board is their personal work queue. Do not widen the developer scope in review.
Reuse the shipped roles foundation (#1527/#1528) — `internal_role`, `can_move_forward`, `next_status_id`, server-narrowed `allowed_statuses`, `is_assignee` — rather than adding a parallel role check. The frontend must be able to derive drag rights from the payload alone, so **no role name and no status-order array may cross the wire** for the client to interpret (the internal-roles rule, `docs/superpowers/specs/2026-07-31-internal-roles-design.md`).
## Acceptance criteria
- [ ] **Parametrised test over every viewer kind** (ordinary client, developer-assigned, developer-not-assigned, CTO, admin) asserting the exact visible ticket set of the matrix above. A new role added without a matrix entry fails the test.
- [ ] Every card carries `can_move_forward`, `next_status_id` and server-narrowed `allowed_statuses`, matching what `_detail_out` (`tasks.py:672`) returns for the same ticket and the same viewer.
- [ ] **No existence leak:** a developer requesting a ticket assigned to someone else receives a response byte-identical in status and body to a non-member requesting the same ticket — asserted in a single test comparing both.
- [ ] **Computed once for the whole list:** a call-count / query-count assertion proves no per-ticket detail call and no N+1 as the list grows.
- [ ] **Parametrised over every viewer kind:** status `16` (Backlog) and status `14` (Blocked) never appear in `allowed_statuses` nor as `next_status_id` — consistent with `FORWARD_ORDER` (`domain/tasks/progression.py:78`) and `may_set_status`, which refuses Backlog in both directions even for a CTO.
- [ ] **Credit gate intact:** for a ticket *in* Backlog, `can_move_forward` is false for every viewer kind, and the generic transition route still refuses it with `BacklogNotSubmitted` → 409 (regression test). The board must not become a free way past the gate.
- [ ] **MCP parity:** for a given actor and ticket, `allowed_statuses` on the board equals exactly what MCP `transition_ticket` advertises for that same actor and ticket — preserving the standing rule that MCP grants no capability a browser session lacks, and vice versa.
- [ ] An ordinary client's cards all carry `can_move_forward: false` and an empty `allowed_statuses`.
- [ ] The payload contains no role name, role predicate or status-order array for the frontend to interpret.
- [ ] Gates green: `python -m pytest -q` (**run serially**), `ruff check .`, `mypy src`.