# Design brief — §IR "Internal roles — ticket progression & assignment"
Design-system section appended (additive, +349 lines, 0 deletions): `docs/design/design-system.md` → **§IR**, at the end of the file, in the house format of §TD3 / §CL. It covers **#1527 and #1528 together**, on purpose: both land in the same hero/action region of `TaskDetailPane.tsx`, and designed apart they would fight for the same eight centimetres — the hero row already carries *Submit → Claude → Edit → Pause*, and three more controls would take it to seven and bury the client's only CTA.
**Proof attached:** `design-1527-before.png` · `design-1527-after.png` · `design-1527-mockup.html` (the after mockup, self-contained) · `design-1527-before-reconstruction.html`.
## The two rules everything follows
1. **Rendered from the server's answer, never from a copy of the rules.** No status order and no role predicate in `src/`. Three questions, one field each: *may I advance?* → `can_move_forward`; *where to?* → `next_status_id` / `next_status_name`; *what else may I set?* → `allowed_statuses`, which the server sends **already narrowed to what this viewer may set** (`may_set_status` per candidate target, Backlog stripped in both directions).
2. **A client's pane stays byte-for-byte today's.** Nothing is inserted into `TicketHero` at all — the whole feature mounts as **one sibling band**. For a client the band returns `null`, so the diff to their screen is provably empty in the rendered tree, not a promise about prop plumbing.
`internal_role` from `/auth/me` is deliberately **not** the gate on this screen. Using it would mean the client's payload still carried `assigned_to_name` and the frontend merely declined to paint it — the staff name would sit in the JSON, in the React Query cache and in devtools. Spec decision 7 is about *not shipping the name*, so the gate is **payload presence**: `undefined` = you may not see it, `null` = nobody holds it → "Unassigned".
## Atomic Design — new vs reused
| Component | Level | New? |
|---|---|---|
| `Button`, `Badge`, `TaskStatusPill` | atoms | reused unchanged |
| `Tooltip` §UH.2 | atom | **extended**: optional `id` → drops `aria-hidden`, becomes the `aria-describedby` target (a reason is a description, not a name) |
| `MoveForwardButton` | **molecule** | **new** |
| `TransitionControl` §TB.6 | molecule | **reused, mounted for the first time ever**; `error: boolean` → `error?: string \| null` so 403 and 409 read differently |
| `AssigneeControl` | **molecule** | **new** |
| `TicketFacts` §TD3.7 | molecule | reused unchanged — the caller composes a new "Assigned to" row |
| `TicketHero` §TD3.3 | molecule | **untouched** |
| `StaffActionBar` | **organism** | **new** — the single mount point |
| `TaskDetailPane` | organism | one new sibling in the story column + the rail row |
**`StaffActionBar`** — a recessed band (`rounded-xl border border-border bg-surface-muted`) directly under the hero, sibling to it, after the §CL.2a Claude fallback mount (that rule is not re-opened). Eyebrow label, **not** a heading, so no rung is inserted between the `h1` and the description's `h3`s. Order by frequency, not power: Move Forward (the only `primary` in the band) → Change Status → Assign. Deliberately **not** mirrored into `TicketStickyHeader` — deferred, noted.
## The four viewer states
| Viewer | Move Forward | Change Status | Assign | Assignee name |
|---|---|---|---|---|
| **Ordinary client** (`internal_role: null`) | absent | absent | absent | **absent from the payload** |
| **Developer, assigned** | enabled, *"Move to Design"* | **absent** | absent | rail row only |
| **Developer, not assigned** | present, `aria-disabled`, tooltip | absent | absent | rail row only |
| **CTO / admin** | enabled | full set, backward included | present | trigger + rail |
| **Backlog, any viewer incl. CTO/admin** | **absent** | **absent** | present iff `can_assign` | as above |
## The two decisions you asked for
**1. Developer's one-option Change Status → hide it, Move Forward is the only door.** Render rule, one line, holding no policy: `!isBacklog && allowed_statuses.length >= 2`. The server sends a developer exactly `[next_status]` → length 1 → not rendered; a client `[]`; a backlog ticket `[]`. A one-option menu offers a choice that does not exist: three interactions (open, pick, confirm) to reach the identical mutation the button beside it performs in one, sitting next to that button claiming to be a *different* affordance, containing by construction the button's own label. It is also the **weaker guarantee** — a rendered menu is a thing to force open with devtools and make submit a stale target; an unrendered one is nothing at all. Hiding it makes the AC "no other status is reachable from the developer's UI" true *by construction*, and the test becomes a statement about the DOM rather than about a filter's arithmetic. The server still refuses a forced non-next target — belt and braces, not belt alone.
**2. Disabled Move Forward = `aria-disabled` + focusable + `aria-describedby`, never bare `disabled`.** A bare `disabled` drops the button from the tab order, which makes the *only* explanation of why you cannot act unreachable by exactly the people who cannot mouse-hover it. Click handler short-circuits, `opacity-55`, hover suppressed so nothing promises a state change. Two reason shapes, tested in this order — `next_status_id === null` → *"This ticket is at the end of the pipeline."* (a fact about the ticket, true for everyone), else → *"Only the developer assigned to this ticket can move it forward."* If a third reason ever appears server-side it ships as a **reason code on the payload**; the frontend does not grow a third branch.
## Backlog — the credit gate
**No status control renders for anyone, including CTO and admin** — absent, not disabled-with-an-explanation; the pane must not suggest a second door exists next to Submit. Backlog never appears as a *target* either, because the server never puts it in `allowed_statuses` in either direction; the frontend adds **no filter of its own** (a filter here would be a silent second copy of the gate, and the day the two disagreed the visible one would win for the wrong reason). The `isBacklog` guard uses the `BACKLOG_STATUS_ID` constant already in `src/features/tasks/types.ts` — it already gates Submit, so no new knowledge. **The Assign control survives**: assignment changes no status and spends no credit, and lining work up before the client pays is exactly what a CTO does with a backlog.
## Assign (#1528)
Trigger shows the current holder (initials avatar + name, or "Unassigned"). Menu = **"Unassigned" always first**, hairline, then the project's developers, current one suffixed "· current" + `aria-current` (never colour-only). **Two empty shapes, not one:** `[]` and nobody assigned → `aria-disabled` trigger + note *"No developer is configured for this project."*, no menu opens (a menu that opens onto nothing is a bug the user is asked to interpret); `[]` but someone assigned → the menu still opens with just "Unassigned", because removing someone must not depend on the list that could no longer produce them.
## Errors — inline, adjacent, never optimistic
`role="alert"` block inside the band, under the control that produced it. The pane never blanks: the error is about one control, not about the ticket. 403 → `tasks.progress.forbidden` / `tasks.assign.forbidden`; 409 → the existing `tasks.transitionFailed`, unchanged; 422 → `tasks.assign.unmapped` ("{name} has no Redmine account linked yet…"); upstream → `tasks.assign.failed`.
**There is no optimistic state, and that is the design.** The status pill changes only when the server has answered, so the AC's "optimistic state is rolled back" is satisfied in the strongest available way — there is nothing to roll back, because the pane never displayed a stage nobody had agreed to. In-flight feedback is the button's own spinner + `aria-busy`. Both mutations invalidate the board **and** detail queries **on failure as well as success**: a 403 usually means the world moved, so the right response to being refused is to go find out what is true now — which is what makes the 403 copy ("…has been refreshed") a fact rather than a flourish.
## i18n
16 new keys under `tasks.staff.*` / `tasks.progress.*` / `tasks.assign.*`, EN + FR given verbatim in §IR.6, identical ICU placeholders — both catalogs, since `fr.ts` is a real translation typed to mirror `en.ts`. Reused unchanged: `tasks.status.*` (via `statusLabel`, a `stageKey → key` map, **not** an ordering), `tasks.changeStatus*`, `tasks.confirmMove`, `tasks.move`, `tasks.cancel`, `tasks.transitionFailed`.
## Proof — how it was captured (read this before trusting the screenshots)
The gstack **/browse** daemon is **not installed on this machine** (`~/.claude/skills/gstack/browse/dist/browse` does not exist; `/usr/bin/browse` is the xdg-open wrapper). Screenshots were taken with **headless Google Chrome 150** at `--force-device-scale-factor=2` instead. Stating it plainly rather than implying tooling that was not used.
- **AFTER** — `design-1527-after.png`, rendered from the attached mockup: all five cases (client / developer-assigned / developer-not-assigned incl. the focused tooltip / CTO with both menus open / backlog) plus the error strip, each annotated with the exact payload that produces it.
- **BEFORE** — `design-1527-before.png` is an **HTML reconstruction, not a capture of the live app**: `pipeliner.omdev.tech` is session-walled and no local dev server was running. Anatomy taken verbatim from `TicketHero.tsx`, `TicketFacts.tsx` and `TaskDetailPane.tsx`, so it is faithful to the current code, but it is a reconstruction and is labelled as such on the image itself.
**No new colour anywhere** — §2/§3 tokens only. Design-system edit is uncommitted in the shared checkout, as intended; no git action taken.