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`.
RA Updated by Redmine Admin 1 day ago
## Amendment after the design review (#1545) — these change the ACs
**1. Add `board_read_only: boolean` to the board payload.** The design needs to tell a viewer *why* their board has no draggable cards, and inferring "read-only" from "no card has a capability" is wrong: a developer whose assigned tickets all sit at the end of the pipeline would be told "the team moves tickets", which is false.
- New AC: the board payload carries `board_read_only`, true only when the viewer categorically cannot move anything (an ordinary client), false for an internal viewer even when no *current* card happens to be movable. Parametrised over every viewer kind.
**2. ⚠ Assignee visibility — this REVERSES shipped #1528 behaviour.** PO decision: **the assignee name is shown to every viewer, ordinary clients included.**
#1528 shipped the opposite, deliberately: `_detail_out` (`tasks.py:672`) nulls `assigned_to_id` for non-internal callers so a client cannot learn the staff roster or who works on which account. Leaving that in place while the board shows names would give a client a name on the card and nothing in the detail pane — a visible half-state, worse than either end.
- New AC: assignee is present on the **list** payload for every viewer.
- New AC: the `_detail_out` redaction is **lifted** so the detail pane matches. The #1528 regression test asserting a client sees no assignee must be updated, not deleted — invert it and reference this ticket, so the reversal is traceable rather than looking like a regression someone let through.
- Unchanged: nothing else about the client's pane moves. `StaffActionBar` still returns `null` for a client; the status and assign *controls* stay internal-only. This lifts the withholding of a **name**, not of any capability.
**3. Assignee id is what the developer scope filters on.** #1541 nulls `assignee_ref` entirely rather than half-filling it when the principal is a group or a deleted user, precisely because a ref without a stable id is worse than no ref. A card with a null `assignee_ref` therefore belongs to no developer's scope — assert that a developer's board excludes it rather than including it by accident.
Design section: `docs/design/design-system.md` §KB (KB.0–KB.11).
RA Updated by Redmine Admin 1 day ago
## Precision after #1542 landed ([PR #96](https://github.com/omdev-tech/PipeLiner-Client/pull/96))
#1542 shipped the assignee on the **list** payload for every viewer, and correctly left `_detail_out` alone — lifting that redaction is this ticket's job.
That leaves an inconsistent state on `dev` until this ticket lands: **a client sees the assignee's name on the card and nothing when they open the ticket.** Two tests now encode the two halves:
- `api/tests/test_tasks_api.py::test_an_ordinary_client_never_sees_the_assignee` (~line 516) — the **detail** endpoint, still asserting #1528's withholding. **This is the test to invert.** Rename it to say what is now true, keep the assertion structure, and reference this ticket in the docstring so the reversal is traceable rather than looking like a regression someone let through.
- `api/tests/test_tasks_api.py::test_assignee_is_shown_to_an_ordinary_client_on_the_board` (~line 1902) — the **list** payload, already asserting the new behaviour. Leave it.
⚠ **Shipping constraint:** #1542 must not reach prod without this ticket. Alone it produces the half-state above, which is worse than either end. Ship them together, or ship this one first.
Unchanged: `StaffActionBar` still returns `null` for a client, and every status/assign *control* stays internal-only. This lifts the withholding of a **name**, not of any capability.
RA Updated by Redmine Admin 1 day ago
- Status changed from Spec to In development
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
- branch set to feat/1543-board-visibility-matrix
- pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/97
PR: https://github.com/omdev-tech/PipeLiner-Client/pull/97 — branch `feat/1543-board-visibility-matrix`, based on **`dev`** (this builds on #1542/PR #96 and #1544/PR #95, neither of which is on `master` yet).
## What shipped
**The D2 matrix** is applied in `ListProjectTasks` / `BuildTaskBoard` and expressed as data (`BOARD_SCOPE`) in `domain/tasks/progression.py` rather than as a chain of ifs — so "which roles have a matrix entry" is something a test can read. An unrecognised role fails **closed** to the narrowest scope, and `BOARD_SCOPE` is asserted against `InternalRole` itself, so a role added without a matrix entry turns the build red instead of defaulting quietly. The developer scope is *narrower* than the client's, as decided.
**Per-card capability** is computed in one pass by `card_capability`, which delegates to `may_set_status` / `next_status` / `selectable_statuses` — no rule is re-implemented, and the board, the transition route and MCP `transition_ticket` cannot disagree. Memoised on `(status_id, is-it-mine)`, so a tenfold board costs the same number of rule evaluations and zero Redmine calls.
**`board_read_only`** is on the payload as a property of the *viewer*, per the amendment. A developer whose queue all sits at Shipped has no movable card and is still `false`.
**The `_detail_out` redaction is lifted.** `test_an_ordinary_client_never_sees_the_assignee` was **inverted and renamed** to `test_an_ordinary_client_sees_the_assignee_but_no_controls`, with this ticket in the docstring and the assertion structure kept, so the reversal reads as a decision. Scope respected: only the *name* is disclosed — `can_move_forward`, `can_assign` and the narrowed `allowed_statuses` are unchanged and still give a client nothing.
## Two decisions the reviewer should look at
1. **A card is evaluated against the canonical pipeline** (`FORWARD_ORDER`), not the per-issue Redmine allowed set, because the latter is one HTTP call per card and the spec forbids the fan-out. Backlog(16)/Blocked(14) are therefore never offered to anyone; where a project's workflow is narrower than the canonical pipeline, a card may advertise a target Redmine then refuses, landing as the 409 the drop path already handles. The **role** half of the answer — the half that could leak — is exact. Documented at `BOARD_CANDIDATE_STATUSES`.
2. **Closing the existence leak required the detail route and MCP too.** For a scoped viewer, "not yours", "not in this project" and "you are not a member" all collapse to the same `403 {"detail":"Forbidden"}`. Without this the matrix would be cosmetic — an omitted card could be read back by asking for its id. `MCP list_tickets` is scoped by the same matrix, since the standing rule cuts both ways.
## Tests
New `api/tests/test_board_visibility.py`, 48 cases: the parametrised matrix over every viewer kind × both modes; the byte-for-byte leak comparison; the call-count/N+1 proof at 20 and 200 cards; Backlog/Blocked never offered; the credit gate (inert card **and** a still-409ing transition route); MCP parity and card↔pane parity; the null `assignee_ref` case from #1541; and no role name or status order on the wire.
Mutation-checked rather than assumed: widening the developer scope to `all` fails 7 of them (both leak tests included), and breaking the memo fails the N+1 test.
**Gates green:** `pytest` 1202 passed / 2 skipped, run green **twice on one database**; `ruff check .` clean; `mypy src` clean.
⚠️ Shipping constraint from the amendment still stands: #1542 must not reach prod without this, or clients get the half-state (name on the card, blank in the pane).
RA Updated by Redmine Admin 1 day ago
- Status changed from QA to In development
## Code review: FAIL — back to In development
PR https://github.com/omdev-tech/PipeLiner-Client/pull/97 is **not shippable as-is**. Three blocking security findings, all reproduced against a live Postgres, and B1 independently re-confirmed.
**What is good, and should not be disturbed by the fix:** the matrix itself. The reviewer could not find a way to make `board_scope` / `visible_tasks` / `card_capability` hand a developer someone else's card; the matrix is data and fails closed; ordering is scope→sort→cap; MCP narrows and never widens; both CLAUDE.md MCP invariants hold (`create_ticket` still scores server-side, the Entra allowlist is still re-checked per request); and the assignee test was correctly inverted and renamed with a #1543 reference rather than deleted, with every capability left internal-only.
### B1 — `may_view_task` guards one route out of six
Called exactly once, `presentation/api/tasks.py:997`, inside `get_task`. Six routes return the identical `_detail_out` body — `get_task` (guarded), `transition_task`, `assign_task`, `pause_task`, `reopen_task`, `patch_task`. Verified:
```
GET /projects/{pid}/tasks/3 → 403 Forbidden
PATCH /projects/{pid}/tasks/3 → 200, full body incl. description and assignee
POST /projects/{pid}/tasks/3/pause → 200, same body
```
A developer reads **and mutates** any colleague's ticket by id — pausing it, or overwriting its `spec_ref`. The use cases carry no authorization of their own. Same hole on MCP (`_set_ticket_paused`, `_update_ticket`, `_submit_ticket`, `_transition_ticket`'s `InvalidTransition` branch), which makes the MCP scoping cosmetic too. Fix is a shared `viewable_task` dependency across every `issue_id` route and MCP tool, refusing **before** the write.
### B2 — `transition_task` is an id oracle
`TaskNotInProject` → 404 while a foreign-but-present ticket → 403 with a role-specific message. One POST per id sorts "on my project" from "elsewhere", defeating the collapse `get_task` performs.
### B3 — a non-existent id is a third, distinguishable answer — a 500
`GetTask` lets `RedmineError` escape uncaught, so a missing id 500s where an existing one 403s. Existence is disclosed across the **whole Redmine instance**, other tenants included. Pre-existing, but this ticket is what asserts the property.
### Also required
**S3** — the `redmine_user_id is not None` guard in `holds_ticket` is mutation-survivable: deleting it gives an unmapped developer every unassigned ticket and no test goes red. A developer holding the role with a NULL Redmine id is a real state.
**S2** — the canonical-pipeline tradeoff is sound (it can only ever advertise a target the *workflow* refuses, never one the *role* refuses), but the docstring's claim that it lands as a 409 is wrong for a developer: role is checked before workflow, so it is a 403 on their own ticket.
Fix is in progress on the same branch and PR.
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
## Merged — review passed after three rounds
PR https://github.com/omdev-tech/PipeLiner-Client/pull/97 merged to `dev` (`b50f90c`, +2989/−77). Gates: **1253 passed** / 2 skipped, green twice; ruff and mypy clean; Jenkins PR-97 green.
**Round 2 review cleared the security question outright.** The reviewer enumerated from the live OpenAPI schema rather than by grep — 67 paths, 12 that touch a ticket, all 12 gated, plus all 6 issue-id MCP tools. It then attacked its own conclusion:
- the gate fires **before body parsing** — a malformed `status_id`, a missing field and unparseable JSON all return 403, not 422, with zero Redmine writes;
- the refusal collapse holds across **44 byte-identical responses per route set**, headers included, over four buckets (not yours / other project / doesn't exist in Redmine / not a member);
- nothing widened for clients or CTOs, and the old 500 on a missing id is now a 404.
**Round 3** added the filer admission (PO decision) plus two test-hygiene fixes:
- `owns_or_filed` = "assigned to me **or** filed by me", one predicate behind all four surfaces. Authorship comes from `ticket_charges`, not Redmine's `author` — every dashboard-filed ticket is authored by the shared "Client Dashboard" user, which identifies nobody. The port is a batch intersection (`issue_ids_filed_by`) chosen so an N+1 is not the natural implementation: **one query at 5 cards and at 200**, zero Redmine reads, and unscoped viewers never pay for it.
- Fixed a **vacuous assertion in our own suite**: `assert not writes.called` could never fire, because respx dispatches to the first matching route and the catch-all was registered second. With the gate removed the route returned 200 with the full foreign ticket and `call_count == 0`. Now bound to a route that matches — mutation-confirmed at `call_count == 1`.
- Corrected the docstring claiming one `is not None` guard was load-bearing. Measured: dropping either clause alone leaves the suite green; dropping **both** reddens 2 tests.
Mutation results: removing the filer clause reddens 4 tests, removing the assignee clause 14.
## ⚠ Known window, carried forward
`ticket_charges` rows are **cleared at submit** (`submit_ticket.py:147` — "drop the pinned charge so it cannot be re-read"). Filer-scope therefore lasts only while the ticket sits in **Backlog**.
That covers the operations this decision was about — submit, edit and delete are all backlog-window actions — but a developer who files a ticket, submits it, and is *not* assigned it loses sight of it again at Submitted/Spec, where `ticket_is_mutable` would still have let them edit. Closing it needs a durable authorship record, i.e. a schema change. Tracked separately; documented on `owns_or_filed`.
Also noted out of scope: the **RAG corpus** is built from every ticket's subject and description and retrieval is scoped by project only, so a developer's own chat can be grounded in a colleague's ticket text. Not an id oracle, predates this ticket, and scoping a vector index is a design decision rather than a one-liner.