# W44 — the bag of four: the surfaces a person touches

**Scope: the Operator's Bag, minus the one item that is blocked.** `bags` read
2026-08-03T11:36:18Z held five Requests, all bagged by the Operator, all in Backlog:
**#64, #68, #69, #70, #71**.

- **#68** change: append a ` n` counter to every auto-generated thread name.
- **#69** add: a Discord `/request n` slash command.
- **#70** add: an expiry footer on the messages that expire.
- **#71** fix: `--file` still has its old behaviour for `comment`.
- **#64 is NOT in this wave.** It is blocked on spt-core and the sequencing is ratified
  binding (recorded as a comment on #64 itself): ONE release raises
  `min_spt_core_version` and deletes the retro-apply path in the SAME commit, and doyle
  hands over the exact spt-core version string ahead of publish. No such hand-off has
  happened. Starting it early is the one thing the ruling forbids.

Three of the four are surface work a person sees; #71 is the send seam. They share no
code, so they land as four independent commits behind one gate.

---

## #68 — a thread name carries its Project's index

**The complaint.** `ctrl+F` on "Backlog" in Discord matches the Backlog thread of every
Project at once, because every Project's threads carry identical names. The Requester's
remedy: number them per Project — `Backlog 0`, `Backlog 1`.

**Open design question → decision: what is `n`?** The Project's 0-based position in the
daemon config's `projects` list. It needs no new store, it is stable while the list is,
and it matches the Requester's own example. The cost of the alternative (a persisted
per-Project number) is a second source of truth for something the config already orders.

**The hazard this creates, and the invariant that kills it.** If `n` moves — a Project is
removed from the config and the ones after it shift down — a lookup that matches on the
FULL desired name reads "no such thread" and **creates a second Backlog**, stranding the
first with the Project's whole history in it. That is `REQ-HAZARD-CHANNEL-ADOPT` one level
down. So:

> **A managed thread is matched by its BASE name, ignoring any numeric suffix**, and then
> renamed to the desired display name. Never matched by its display name alone.

That single rule also carries the migration for free: every existing bare-named thread is
matched, adopted and renamed on the first provision pass after this ships. No thread is
ever created twice, and renumbering is a rename, not a rebuild.

**Bare stays the internal identity.** `GuildIndex`/`ChannelEntry.state_threads` keep the
bare name (`"Backlog"`); `desired_hide_minutes` and `state_thread(entry, "backlog")` match
on it and must not learn about suffixes. Only the Discord display name and the Contents
post's link text carry the number — the Contents post because it should read the way the
sidebar reads.

Applies to all 13 managed threads (8 State + 5 aux), which is what "all auto-generated
threads" asks for.

## #69 — `/request n`

An ephemeral card-at-a-glance: the title as a header, the current state, and both links
(Discord post + GitHub issue).

- **Truth comes from GitHub**, not from the card: title, state and issue URL are read from
  the issue, because GitHub Issues is the system of record and a card can be one cycle
  behind. The board is consulted for exactly one thing it alone knows — where the card is.
- **The Discord post link** comes from `reconcile::scan_channel`, which already maps
  `issue number → posts` for a channel and is the tested reader for this. No card yet (or
  no card any more) is a normal answer, not an error: the reply says so and still carries
  the GitHub link.
- **Where it may be invoked**: inside a Project's channel or any of its threads — that is
  what names the Project. Anywhere else is an ephemeral refusal naming the reason.
- Registered guild-scoped and idempotently by name, exactly like `/alchemy-help`.
- `/alchemy-help` gains one line for it, or the command is undiscoverable from the surface
  that exists to make the board discoverable.

## #70 — an expiring message says when it expires

Two kinds of bot post expire today, both swept by `sweep_expired_posts`: the **mint
receipt** (12h) and the **bag-drop notice** (8h). Both gain a last line:

```
-# *This message expires at <t:1785790000:t>*
```

**Open design question → decision: literal `HH:MM` or a Discord timestamp?** The Request
writes `HH:MM`. A literal HH:MM would be the *daemon host's* clock rendered into a message
read by people in other timezones — right for the machine, wrong for the reader. Discord's
`<t:EPOCH:t>` renders as a short local time in each reader's own zone, which is what
`HH:MM` was asking for. Taken, and called out in the changelog.

- The instant is computed at **compose** time as `now + TTL`. The sweep keeps deriving age
  from the message's own snowflake and is untouched — the footer is display only, and the
  sub-second gap between composing and posting cannot be read off a minute-resolution
  stamp.
- **Both recognizers must survive the new line** (`is_receipt` matches mid-string,
  `is_bag_drop_notice` matches `starts_with`) — tested against the REALLY composed text,
  the way #65's marker drift is, because that drift fails nothing loudly: it just leaves
  the message up for ever.
- **Scope**: the messages that *expire*. Ephemeral interaction replies are not in scope —
  they do not expire on a clock and Discord already labels them.

## #71 — a file sent with a comment attaches to that comment

**Measured, not assumed** (2026-08-03, against `alchemy-1`/`alchemy-testbed#43`):

```
comment 5165835117 added to BigscreenVR/alchemy-testbed#43
file 'n71-probe.txt' staged (52 bytes) — your next create reads its text as the request body…
```

The comment posted with **no** attachment, and the file then staged itself against the
sender's next `create`. So the defect is not in the `comment` verb — `comment` already
attaches whatever staging holds when it runs (`REQ-ATTACH-DRAFT-RELEASE`). It is the #52
frame-ordering defect: one `send <text> --file <path>` delivers `shell_text` **first**, so
the write consumes nothing and the file lands stranded a moment later. `create` has a
retro-apply window for exactly this; `comment` has none.

**The fix**: give `comment` the same window. A file frame arriving within
`RETRO_APPLY_WINDOW` of the sender's own comment is edited into that comment — always as
an attachment, never as the body, which is the contract doyle stated. The insertion lands
**before** the endpoint signature so the signature stays last, mirroring how the create
side keeps the Requester footer last.

**Retirement coupling — binding, and the reason it is written here.** This extends the #52
mitigation, and #64 deletes that mitigation in one commit the moment spt-core orders the
frames. The comment side must come out in that SAME commit. Recorded in the code at the
site, in `docs/KNOWN-HAZARDS.md`, and as a comment on #64 — a mitigation whose correctness
depends on the defect it mitigates cannot be left for someone to find.

`github.rs` gains `get_comment` (the symmetric read for the `update_comment` that is
already there).

---

## Requirements (`traceable-reqs.toml`, group `w44-touch-surfaces`)

| id | Request | stages |
|---|---|---|
| `REQ-THREAD-NAME-PROJECT-INDEX` | #68 | doc, impl, unit |
| `REQ-DISCORD-REQUEST-COMMAND` | #69 | doc, impl, unit |
| `REQ-EXPIRING-POST-FOOTER` | #70 | doc, impl, unit |
| `REQ-COMMENT-FILE-RETRO-APPLY` | #71 | doc, impl, unit |

## Tasks

1. `traceable-reqs.toml`: the group + four requirements, activated.
2. #68 — suffixed display names, base-name adoption, Contents post, tests.
3. #70 — footer on both composed posts, recognizer drift tests.
4. #69 — `/request`, its handler, `/alchemy-help` line, tests.
5. #71 — `get_comment`, comment retro-apply, retirement coupling recorded, tests.
6. CONTEXT.md doc sections for all four.
7. Gate: `cargo test` + `traceable-reqs check` green.
8. Release: bump, changelog, build, pack, publish, install, then `sweep` then
   `release <tag>` as part of the publish.

## Gate

`cargo test` green, `traceable-reqs check` exit 0, and the four Requests promoted by the
`release` verb on the published tag.
