# W32 — The card acts

Second wave of **Milestone #38** (*the control surface: what the board can do*). Two
Requests, both on the Discord board, both about a Bot post doing something rather than
merely showing something: **#11** puts a State control on a Request card, **#18** makes a
release announcement a card. One board read closes both.

W31 shipped as **v0.8.0** and is closed by the installed binary. This wave is the first of
the four that end in the Operator's eyes.

## The constraint, restated because it decides the shape

**serenity 0.12.5 cannot put a select menu in a modal** — `ComponentType` knows 1–8, the
modal-select needs the `Label` wrapper (18), and the return leg fails independently
(`edit_modal_submit` matches `ActionRowComponent::InputText`; an unknown component
deserializes to `Unknown(u8)`). So #11's State picker is **a select in an ephemeral
message**, never a dropdown appended to the existing Edit form — which is the literal shape
the Request asks for ("include a `State` dropdown menu at the bottom of the request edit
form"). The Operator ruled the substitution at the grill; the plan honors the *capability*
the Request wants, not the widget placement it names. Do not re-litigate.

## Checkpoint result — the confusion audit, and what it changed

`req-confusion-audit` ran at the top of this wave per `M38-PLAN.md`. Coordinator-scored,
78/78 within-group pairs dense, cross-group probes named rather than exhaustive. Full
scores: the run's temp `m38-confusion-scores.jsonl`. Two results bind this wave:

1. **`REQ-CARD-STATE-CONTROL` ↔ `REQ-EDIT-QUIET-ON-SUCCESS` scored 64 — and that is a
   SCOPE finding, not a wording one.** #11's requirement title re-states, in substance, a
   rule W29 already shipped for *every* Discord-side write: a landed change says nothing
   and lets the refreshed card speak; a failure or refusal still answers in the ephemeral.
   **Ruling for this wave: the State control INHERITS that rule and does not re-own it.**
   Concretely — reuse `botpost::ack_text` and `botpost::with_progress_mark` rather than
   writing a second answer policy, and tag the quiet/loud behavior to
   `REQ-EDIT-QUIET-ON-SUCCESS` and `REQ-EDIT-PROGRESS-MARK` where it is already theirs.
   `REQ-CARD-STATE-CONTROL` owns the *control*: which States it offers, what it refuses,
   and the confirm round-trip.
2. **`REQ-RELEASE-CARD` ↔ `REQ-RELEASE-ROUNDUP-WINDOW` scored 44** — form vs content of the
   same announcement, neither title leading with which half. #18 changes the **form and the
   dedup key only**. The roundup window is W28's and is not touched; a W32 change that
   alters which Requests appear in a release post is out of scope and is a defect.

Five discriminability retitles were proposed and **are not applied** — audit mode does not
authorize edits and the authorization was not given. Tracked as a task; the wave proceeds
without them, which costs nothing today because the two ids this wave tags are the two the
proposals leave semantically unchanged.

## A — #11: the Change State control

`REQ-CARD-STATE-CONTROL` → `["doc", "impl", "unit"]`.

The Bot post already carries the Needs Operator toggle and the Edit control, both of which
are buttons whose `custom_id` round-trips the ref (`botpost::parse_button_id`). This is a
third button on the same rail, and everything about its plumbing is precedent.

- **T1 — impl. The button.** `botpost::state_button_id(owner, repo, number)`, parsed by the
  existing inverse, rendered on **Request** cards. Milestone cards do not get one this
  wave: a Milestone's State transition cascades to its sub-issues, and a cascade behind a
  single click with no preview is a different Request than the one greenlit here.
- **T2 — impl. The select.** The click answers with an ephemeral carrying a `StringSelect`
  over **every State except Greenlit**. Greenlit is Milestone-owned, so it is not offered —
  and, because a select's values are client-supplied and a crafted payload is a real
  submission path, an arriving `greenlit` is **refused by name in the ephemeral** rather
  than assumed impossible. The refusal text is the verb's, so the two surfaces say the same
  thing about the same rule.
- **T3 — impl. The confirm round-trip on Cut and Shelved.** Both terminal-ish States take a
  second confirming click before anything is written. The reason is parity, and it is
  stated in the requirement: the *tag* path already makes a destructive move take a confirm
  round-trip, and a human surface that is a looser gate than the agent surface inverts the
  safety of the whole board. Backlog/Eval/WIP/Acceptance/Done write on the first click.
- **T4 — impl. ONE State-transition authority.** The write must produce the same label
  swap, the same GitHub open/closed state and the same close reason as `state #N <state>`
  (ADR-0004). Today that logic is `Engine::apply_state`, private to `verbs.rs`, and the
  daemon has no access to it. **Extract the decision** — labels-after, `gh_state`, reason —
  into one pure function both callers use; the daemon keeps its own HTTP call, but not its
  own rules. A second transition authority that disagrees about `state_reason` is the exact
  defect class this project treats as an invariant, and it would be invisible on the board:
  the label would look right and the issue's close reason would be wrong.
- **T5 — unit.** Greenlit is absent from the offered vocabulary AND refused on arrival.
  Cut and Shelved do not write on the first click and do write on the second. Every other
  State writes on the first. The transition produced by the button path is **byte-identical
  to the verb path's** for the same issue and target — one test, both callers, which is
  what makes T4's extraction load-bearing rather than cosmetic.

## B — #18: releases as cards

`REQ-RELEASE-CARD` → `["doc", "impl", "unit"]`.

Today an announcement is a plain message whose **first content line** is the machine key
(`reconcile::release_marker` → `release: <tag>`), and dedup is a prefix scan over the
thread's own bot messages. The card moves the key into the embed footer, which is where
every other Bot post already keeps its machine key — and that move is the whole hazard.

- **T6 — impl. The card.** `botpost::release_card(...)`: title carrying the release name,
  the roundup as the description (composed exactly as it is today — W28's window, untouched),
  a machine footer key, and **its own color**, not a State's. A release is not in a State;
  borrowing one makes the Releases thread lie by palette.
- **T7 — impl. Dual-shape dedup, and conversion in place.** Recognition accepts **the footer
  key AND the legacy content marker**. This is not politeness toward old data: the Releases
  thread on a live board is full of plain posts, and a recognizer that only knows the new
  shape re-announces every release the Project has ever shipped, in one cycle, at every
  Alchemist. Then: an existing plain post is **edited into a card in place** — same message,
  same permalink, same position in a thread the board promises reads oldest-first
  (`REQ-BOARD-CHRONOLOGICAL`) — never deleted and re-posted.
- **T8 — impl. Drift correction.** The card re-renders each cycle and diffs like a Request
  card, so an edited release (retitled, notes rewritten) updates rather than fossilizing.
  The render/read pair must stay exact or a consistent post diffs to a mutation every cycle
  — `REQ-HAZARD-RECONCILE-IDEMPOTENT`'s bar, and the trap `REQ-CARD-IMAGE` already fell into
  once when Discord rewrote a stored url.
- **T9 — unit.** A thread of legacy plain posts converts in place with **zero new messages**.
  A second pass over the converted thread performs **zero mutations**. An edited release
  produces exactly one edit. A release the thread has never seen is posted once, as a card.

## Docs

`CONTEXT.md` carries the doc stage for both: the State control in the **Bot post** entry
(which already describes the action buttons and the Needs Operator toggle), and the release
card in the **Releases thread** paragraph that `REQ-RELEASE-ROUNDUP-WINDOW` already sits in
— stated as *form*, with a sentence naming the roundup window as the neighbouring promise
so the 44 does not become a 60 once both have doc text.

No manifest change: neither Request adds a verb.

## Gate

`cargo test` green, `./traceable-reqs.exe check` exit 0, then **the Operator's read of the
board** — which is what acceptance means for every wave from here. A Request whose unit
tests pass and whose board is wrong is not done, and this project has shipped that mistake
three times (v0.4.2, W26, #30). Publish the release on the green gate — release authority is
the DRI's — but do not call #11 or #18 Done on it.

What the Operator is being asked to look at, precisely:

1. A Request card in a State thread, its **Change State** control, the select's contents,
   and that Cut asks twice.
2. The Releases thread **after** the converting cycle: same posts, same order, now cards —
   and no flood.

## Not in this wave

- **Greenlit from the board**, and any State control on a Milestone card. Both are the
  cascade, and the cascade wants its own Request.
- **The roundup window.** W28 owns it. #18 is form.
- **#25**, which keeps its precedence over #26 (W33) if it is open when that wave starts.
