# W28 — The board surface

Milestone [#33](https://github.com/BigscreenVR/spt-progress-tool/issues/33), nine Requests.
Every one of them is about what the Discord board **shows**: the order it shows things in,
what a release post claims, whether a picture survives the trip, what a channel is called,
and what the board does when nobody was watching. Two are silent-loss bugs (#31, #27); the
rest are surface the Operator has asked for and does not have.

Grouped for build order — later groups depend on earlier ones' seams, not the reverse.

## A. Ordering and truth in what is already posted (#31, #27)

**#31 — a pre-existing Project boards backwards.** `list_issues` and `list_releases` return
GitHub's newest-first listing, and `reconcile_channel` / `announce_releases` post in the order
they iterate. Discord renders a thread oldest-at-top, so the first cycle over a Project with
history lays it out newest-at-top: exactly reversed. It is invisible on a Project that grows
one Request at a time, which is why it only showed up on adoption.

- **T1 — impl.** Order the work, not the fetch: both loops sort ascending before they post
  (`created_at` then `number` for issues, `published_at` then `id` for releases) so the board
  reads oldest→newest whether it was built one mint at a time or all at once. Sorting inside
  the reconcile — not at the call site — is what makes the property hold for the targeted
  after-write refresh too.
- **T2 — unit.** A batch of out-of-order issues/releases posts in ascending order; the
  consistent second cycle still mutates zero times.

**#27 — every release post claims the last release's Requests.** `announce_releases` computes
`shipped` as *every* Done Request closed at or before this release's publish time. That set
only grows, so release N names everything release N−1 named, plus its own. The roundup is
cumulative where the Operator reads it as incremental.

- **T3 — impl.** The roundup is the release's **window**: Requests closed after the previous
  published release's publish time, up to and including this one's. The previous release is the
  latest published one strictly before it (draft releases are not releases). The first release
  keeps an open lower bound, so nothing that shipped before the first announcement is lost.
- **T4 — unit.** Two releases, two disjoint roundups, no Request named twice; the first
  release still claims everything before it; a draft between them is not a boundary.

## B. Images (#28, #30)

**#28 — an image attachment links as a link.** `attachments_block` writes `- [name](url)`
for everything, so GitHub renders a filename, not the picture. Known image extensions get
markdown image syntax and embed as a frame.

- **T5 — impl.** `attachments_block` emits `- ![name](url)` when the asset name is a known
  image type (`png jpg jpeg gif webp bmp`, case-insensitive), the existing link shape
  otherwise. One composer, so the `--file` path and the Seed-carry path agree by construction.
- **T6 — unit.** Mixed block: image rows carry `!`, non-image rows do not; the block's
  position and the Requester footer are unchanged.

**#30 — image attachments ride Request posts.** The card mirrors the body but the picture
never reaches Discord. An embed carries one image; the card takes the Request's first.

- **T7 — impl.** `BotCard` gains `image: Option<String>`, filled from the first image link in
  the issue body's attachments block (both the `![]()` shape #28 now writes and the older
  `[]()` one, so live Requests light up without an edit). The serenity read side reads the
  embed's image back, so equality-based drift detection still converges to zero.
- **T8 — unit.** A Request with an image gets the card image; one without gets `None`; the
  legacy link shape is recognized; a non-image attachment is not promoted to an image.

## C. Receipts and progress (#16, #15)

**#16 — a mint leaves the Operator nowhere to click.** The Seed is deleted and its mirror
entry with it, so the person who just reacted has no path to the Request they created; the
Updates thread has the note, but they are standing in the channel.

**Constraint, stated because it shapes the build:** Discord ephemerals are interaction-scoped
and a reaction is not an interaction — there is no token to answer with, so a literally
ephemeral message is not reachable from the mint path. The property the Operator is buying is
the one CONTEXT.md already states — *"a Project's channel therefore holds only un-promoted
Seeds"* — so the receipt is posted in the channel and **deletes itself**, which satisfies that
invariant on a delay instead of never violating it. A DM would satisfy the invariant too but
is not "dropped" where they reacted.

- **T9 — impl.** On a successful mint, post `<reaction emoji> promoted: [#N](card url) | title`
  in the channel and delete it after a bounded TTL. The emoji is the one that was clicked —
  the Type is the thing the receipt is confirming. Plain tail rules apply (`plain_tail`).
- **T10 — unit.** The composer: the clicked emoji outside the brackets, the ref inside, the
  title as a tail carrying no late terminator, the url the fresh card's.

**#15 — the Edit modal writes in silence.** Between submit and the refreshed card there is a
GitHub round trip with no sign anything is happening.

- **T11 — impl.** The modal submit reacts 🔄 on the Bot post it was opened from, then removes
  the reaction once the write returns (either way — a failed write must not leave the marker
  spinning forever). Needs `remove_reaction` on the port.
- **T12 — unit.** The port records add-then-remove around the write; the failure path removes
  it too.

## D. Structure (#5, #2)

**#5 — the Contents post has no header naming its Project.** A masked link header to the
repo, per the Operator's shape.

- **T13 — impl.** `contents_body` opens with `# [<owner>/<repo>](https://github.com/<owner>/<repo>)`.
  The post is found today by `starts_with(CONTENTS_HEADER)`, and the header displaces that
  marker — so recognition moves to a predicate that accepts both the new shape and every live
  post written before it. Getting this wrong forks the pinned post into duplicates, so it is
  the part with the test.
- **T14 — unit.** The header renders first and links the repo; a legacy post is still
  recognized, edited in place, and not reposted.

**#2 — the generated channel name.** `_spt-progress-tool` → `⚙spt-progress-tool`.

The `_` is not decoration: it is the collision guard *and* the string `is_seed` gates on, the
key `project_for_channel` resolves by, and the suffix of the per-Project Alchemist role name.
Changing the visible name must not change capability.

- **T15 — impl.** `channel_name` yields `⚙<repo>`; a channel already provisioned under the
  legacy `_<repo>` name inside our category is **adopted and renamed in place** — never
  duplicated, which would strand the Project's whole history in an orphan channel. Ownership
  tests (`is_seed`, `project_for_channel`, foreign-channel exclusion) accept both prefixes.
  The Alchemist role name stays keyed to the **stable** legacy form, derived from the repo
  rather than the display name: renaming a Discord role to follow the channel would put every
  member's write capability behind a mutation that can fail, and the role name is an internal
  identifier nobody reads off the board.
- **T16 — unit + hazard.** `REQ-HAZARD-CHANNEL-ADOPT`: with a legacy channel present, a
  provisioning run creates no second channel, and the capability held by its Alchemists
  survives the rename.

## E. Recovery (#1)

**#1 — Seeds posted while the bot was offline are never seen.** Capture is gateway-driven, so
a Seed written during an outage gets no Type reactions and no mirror entry, and it is invisible
to the Alchemist who would promote it. Everything else in this system is stateless-reconcile;
capture is the one surface that is not.

- **T17 — impl.** Each cycle, per Project channel: read the channel's recent human messages,
  keep the ones `is_seed` accepts, drop the ones already mirrored (`seed_entry_matches` — the
  same key the edit and delete mirrors use), and run the capture path on the remainder:
  pre-seed the Type reactions, post the mirror entry, post the Updates note. Converged, that
  set is empty and the cycle stays at zero mutations.
- **T18 — unit.** An unmirrored Seed is recovered with reactions and an entry; a mirrored one
  is untouched; chat is not a Seed; a second pass mutates nothing.

## Gate

`cargo test` green, `./traceable-reqs.exe check` exit 0, then ship per `docs/RELEASE-RUNBOOK.md`
through `spt adapter update alchemy` (the holding path). Registry entries land **before** the
code that satisfies them.

**What unit-green does not close.** #31, #27, #28, #5, #2 are provable in unit tests — their
subject is a string or an ordering this code owns. #30, #16, #15 and #1 all end in *how Discord
renders or behaves*, which this project has shipped wrong twice on unit-green alone (v0.4.2,
W26). Those four close on the Operator's read of the live board, not on my test run.
