# W35 — Finalize · Request #10, second half (Milestone #38)

W34 made a Target and a place to assemble one. This wave spends them: **Finalize** turns
an Alchemist's Targets into a Milestone with those Requests as sub-issues, consumes the
Targets it spent, says so once in Updates, shows the Milestone on each Request's card, and
gives an agent a `targets` verb to read the same board the staging post reads.

Activates `REQ-MILESTONE-FINALIZE`, `REQ-CARD-MILESTONE-LINE`, `REQ-TARGETS-VERB`.

## What is already built (do not rebuild)

- `targets.rs` — the pure core: `resolve` over (reaction, carried), `carried_of`,
  `with_carried`, `TARGET_EMOJI`, `is_targetable_thread`.
- `staging.rs` — the post, its footer key, `Intent`, `finalize_button_id` /
  `cancel_button_id` / `parse_staging_button`, `board_targets` (the whole board's Targets,
  oldest card first), `staging_card_for`, `refresh_staging`.
- `run.rs` — `/new-milestone` and `/pick-milestone` (both open/edit the post; `Pick`
  already refuses at invoke when no Milestone sits in Backlog or Eval), `staging_button`
  (Cancel done; Finalize answers "not wired yet"), the ephemeral-select pattern from the
  Change State control, the modal submit path, `target_roster`.
- `github.rs` — `create_issue`, `add_sub_issue` (takes a database **id**, not a number),
  `list_sub_issues`, `list_issues`.

## Open design questions, decided here

1. **Where the assignment is read.** At submit, from Discord, via `board_targets` —
   never from the staging post's rendered lines. The post is a view; between its last
   refresh and the click an Alchemist may have targeted or un-targeted anything. The post
   is not even read on the Finalize path (its `custom_id` already carries invoker+intent).

2. **Order of the two writes.** Assign first (GitHub), consume second (Discord). A crash
   between them leaves the Request assigned AND targeted — visible, and it re-assigns next
   round onto the same parent, which `add_sub_issue` already treats as a no-op. The
   reverse order can lose a Target with nothing to show for it.

3. **Consumption clears BOTH shapes.** A Target is the union of a live reaction and the
   card's carried line, so consuming one shape leaves the other standing.
   Card edit (drop the invoker from the carried line) **first**, then
   `remove_user_reaction` — the same order the un-target gesture uses in `reconcile`, for
   the same reason: a crash between leaves the Request targeted, never half-dropped.

4. **A failed consumption is announced, twice.** In the invoker's ephemeral (they are
   standing there) and in the Updates note (the ephemeral is gone in a minute and the
   consequence — a silent re-assign next round — is not). Naming the Requests, not a
   count.

5. **Finalize deletes the staging post.** The assembly is finished; a post whose list is
   now empty reads like a failure. Cancel already deletes. A Finalize that assigned
   nothing (no Targets) refuses instead, changes nothing, and keeps the post.

6. **The milestone line's source.** The reverse of `milestone_subs`, which
   `reconcile_entry` already fetches for Milestone cards — no extra call. That map is
   fetched all-or-nothing (one `spawn_blocking`, `?` on failure, whole cycle returns), so
   a partially-read map is not representable and the line cannot be dropped by a half
   fetch. A Request in two Milestones takes the lowest-numbered parent, deterministically.

7. **The `targets` index.** The daemon writes `targets.json` in the config dir each cycle
   (`board_targets` per channel, which the cycle now computes **once** and hands to both
   `refresh_staging` and the index writer). The verb reads that file, filters to the
   instance's bound repo, and states the as-of time. No IPC, and no answering from GitHub
   truth — GitHub does not hold Targets at all.

8. **Alchemist names in the index.** Ids alone make a useless agent answer. The port gains
   `role_members_named` (the same guild-members read `role_members` already makes; names
   are on the members it already has). Index rows carry id **and** name; the verb prints
   the name.

## Tasks

### T1 — the doc stage, and the registry before the code
- `traceable-reqs.toml`: `REQ-MILESTONE-FINALIZE`, `REQ-CARD-MILESTONE-LINE`,
  `REQ-TARGETS-VERB` → `required_stages = ["doc", "impl", "unit"]`, dated to W35.
- `CONTEXT.md`: Finalize paragraph after the staging-post one (`[doc->REQ-MILESTONE-FINALIZE]`);
  the Milestone line on the Bot post (`[doc->REQ-CARD-MILESTONE-LINE]`); the `targets` verb
  (`[doc->REQ-TARGETS-VERB]`) **and** `targets` added to the vocabulary line — that line and
  `manifest.toml` and `runtime.rs`'s arg table move in this wave's commits together or
  `REQ-VOCAB-PARITY` fails, which is the check working.

### T2 — the Milestone line on a Request card · `REQ-CARD-MILESTONE-LINE`
- `botpost::card` takes `milestone: Option<&MilestoneRef>` (number, title, url) and renders
  `Milestone [#N](url) — <plain tail title>` in the **head block**, after the flags line and
  before anything `with_carried` appends. No Milestone → **no line at all**.
- `reconcile_channel` builds the reverse index from `milestone_subs` once per channel.
- Tests: rendered line shape and its absence; `[unit->REQ-LINK-TEXT-MACHINE]` /
  `[unit->REQ-LINK-TAIL-NO-TERMINATOR]` on a hostile title; and the idempotence bar — a
  second reconcile pass over a Request in a Milestone mutates nothing, including a card
  that also carries a Target line (the two head-block writers must not fight).

### T3 — the Finalize decision core · `src/daemon/finalize.rs` (pure)
- `assignment(board, invoker) -> Vec<TargetedRequest>` — the invoker's Targets, board order.
- `consumption(card, invoker) -> Option<BotCard>` — the card minus this Alchemist's carried
  mention, `None` when it carries none (nothing to write).
- `update_note(milestone_number, title, url, refs) -> String` — **one** note per Finalize.
- `failure_line(refs) -> String` — the announcement for Targets that would not consume.
- `refusal_no_targets()` — the answer for a Finalize with an empty assignment.
- Tests: assignment ignores another Alchemist's Targets; consumption leaves a co-Target's
  mention alone; the note names the Milestone and every ref; replaying consumption on its
  own output asks for nothing.

### T4 — the two Finalize surfaces · `REQ-MILESTONE-FINALIZE` (run.rs)
- `staging_button` "finalize": `Intent::New` → modal (`alch:mnew:<invoker>`, title short +
  body paragraph — free text is what a modal is good for). `Intent::Pick` → ephemeral
  **select** (`alch:mpick:<invoker>`) over open `kind: MILESTONE` issues in Backlog or Eval
  only; empty list refuses by name (it can go empty between invoke and click).
  `parse_staging_button` learns both ids; the modal router learns `alch:mnew:` before
  `parse_modal_id`.
- Both submits run one path: read roster → `board_targets` → `assignment` → refuse if empty
  → (New: `create_issue` with `kind: MILESTONE` + `state: BACKLOG`) → map number→database id
  from one `list_issues` → `add_sub_issue` per ref → consume each Target (card edit, then
  `remove_user_reaction`), collecting failures → post ONE Updates note (plus the failure
  line when there are failures) → delete the staging post → ephemeral answer naming the
  Milestone, its refs, and anything unconsumed.
- Only the invoker may press (already enforced); Alchemist capability is the button's gate.
- Tests over `FakeGuild`: assignment reads Targets placed AFTER the post was rendered;
  consumption clears reaction and carried line; a consumption failure is announced and the
  Target survives; one note per Finalize; a Finalize with no Targets writes nothing.

### T5 — the index and the `targets` verb · `REQ-TARGETS-VERB`
- Port: `role_members_named`. Index writer: `targets_index.rs` — `{as_of_ms, as_of, projects:
  {owner/repo: [{number, title, state, alchemists:[{id, name}]}]}}`, written atomically to
  the config dir each cycle from the `board_targets` the cycle already computed
  (`refresh_staging` now takes it as a parameter rather than reading the board again).
- Verb: `targets [opts]` (`--json`), answered node-locally in `handle_command` and
  `handle_text` (like `daemon`/`help` — no GitHub, no bind requirement beyond the filter),
  grouped by Alchemist and by State with **either grouping omitted when uniform**, stating
  the as-of time so a reader knows it is cycle-fresh, not live. A missing index answers
  "the daemon has not written one yet", not an empty list.
- `manifest.toml` `targets = { args = ["opts"] }`, `runtime.rs` order table, `verbs::help_text`.

### T6 — gate, then ship
`cargo test` green · `./traceable-reqs.exe check` exit 0 · version bump + CHANGELOG ·
push, tag, `gh release create`, `spt adapter update alchemy`, then compare the daemon's
process start against the exe mtime (`docs/RELEASE-RUNBOOK.md`). Release authority is the
DRI's — no authorization round-trip.

## Gate

`cargo test` green and `./traceable-reqs.exe check` exit 0, evidence tagged in the same
commit as the code, and the behavior verified against the **installed binary**.

## Acceptance — and what this wave cannot close

Finalize is a board act: an Alchemist reacts 🎯 in Discord, presses Finalize, and reads the
Milestone. **I have no Discord client to act as a member**, so the live proof is the
Operator's — as it is for W32's #11/#18 and W34's carry. Unit tests over `FakeGuild` plus
the installed binary are what this wave can prove; none of those Requests is Done until the
Operator reads the board.
