Feature #1525
closedFeature #1510: Implement internal user roles with ticket progression and assignment controls
Assign route, project developers list, and the can_assign capability flag
0%
docs/superpowers/specs/2026-07-31-internal-roles-design.md
Description
**Surface:** backend-only
Expose assignment over the API: a route to set a ticket's assignee, a project-scoped list of assignable developers, and the `can_assign` flag on the ticket payload so the frontend renders from server truth instead of re-deriving the rule.
`can_assign` lives here — shipping it alongside the transition work would have been a flag for a feature that did not exist yet. This subtask needs only the Redmine port work, **not** the transition gate.
Assignment changes no status, so it is deliberately permitted at any stage including Backlog: it cannot bypass the credit gate, and a CTO should be able to pre-assign work before it is paid for.
Per spec decision 6, the developers list is scoped to **project members**, not all internal staff — otherwise the ticket panel becomes a staff directory and tells every tenant who else works here.
## Acceptance criteria
- [ ] `POST /projects/{id}/tasks/{issue_id}/assign` accepts `{"client_id": <int>}` and `{"client_id": null}` (unassign), writes through the Redmine port, and returns the refreshed `TaskDetailOut`.
- [ ] Authorization goes through `may_assign` → 200 for a CTO member and for an admin; 403 for a developer, an ordinary client and any non-member.
- [ ] Target validation: the target must be a member of the same project, have `internal_role == "developer"`, and a non-null `redmine_user_id` — each failure returns a **distinct, actionable 422**; a `client_id` belonging to another tenant is refused without disclosing whether it exists.
- [ ] A foreign or unknown issue 404s via `GetTask` before any write reaches Redmine.
- [ ] A Redmine-side rejection of the id surfaces the mapped 4xx from the adapter subtask — never a 500 (respx returning 422).
- [ ] **HARD:** assigning never changes the status — a Backlog ticket is still status 16 afterwards, and `ticket_charges` is untouched (explicit test).
- [ ] Assigning an already-assigned ticket reassigns idempotently.
- [ ] `GET /projects/{id}/developers` returns `[{client_id, name, email}]` for the project's developer-role members **with a Redmine mapping**, ordered by name; 403 for an ordinary client; a project with no eligible developer returns `[]`, not an error.
- [ ] `TaskDetailOut` gains `can_assign: bool`, true only for cto/admin members.
- [ ] No MCP assign tool in v1 — stated explicitly in the spec so the omission is a decision, not an oversight.
- [ ] `python -m pytest -q` (serial), `ruff check .`, `mypy src` green.