# W47 — Request #73: the board scan reads the thread, not its first page

**One defect, three faces.** `Done 3` in `⚙spt-bs-releases` receives repeated bursts of Bot
cards resurfacing Requests marked done hours, days, or weeks ago, each with its own Updates
note. The cause is a windowed read that the daemon reasons from as if it were the whole store.

## Diagnosis — CONFIRMED at source, trigger MEASURED

**CONFIRMED (source, this repo):** `DiscordAdapter::bot_messages` (`src/daemon/run.rs:2673`)
issues one `GetMessages::new().limit(100)` — Discord's per-request maximum — and returns that
page as the channel. `reconcile::scan_channel` builds its `number → posts` map from it, so a
card sitting deeper than the newest 100 messages of its thread is *invisible*. `reconcile_channel`
then finds `existing` empty, takes the `None` arm, and posts a **fresh card plus an Updates
note** for a Request that has had a card all along. Every re-post is itself a new message that
pushes another card out of the window, so the condition sustains itself: once a thread crosses
100, it floods on every cycle, for ever.

**MEASURED (GitHub truth, 2026-08-21):** Requests carrying `state: DONE` — spt-bs-releases
**117**, spt-progress-tool 55, alchemy-testbed 19, spt-bs-core 0. The Done thread holds one card
per Done Request, so exactly one Project is over the cap, and it is the one the Request names.
The window boundary predicts which channel floods.

**CONFIRMED (source):** no test could have caught it. `FakeGuild::bot_messages`
(`src/daemon/testutil.rs:238`) returns *every* message in the channel. The double is strictly
more capable than the port it stands for, so the window does not exist anywhere in the suite.

### The two faces underneath

1. **Duplicates accrete permanently.** The original card is out of window, so the dedupe never
   sees it. The thread grows by the flood every cycle.
2. **The cleanup would keep the wrong card.** `keep` takes the FIRST post in the wanted thread
   and `bot_messages` is newest-first, so the survivor is the newest flood copy and the original
   is deleted — moving a months-old card to the bottom of a thread `CONTEXT.md:356` says reads
   oldest-first. Completing the scan without fixing this converts a flood into a shuffle.
3. **The Contents post has been under-counting.** `provision.rs:440,454` count the same capped
   read, so a thread past 100 entries has been displaying 100.

## Scope

| Surface | Change |
|---|---|
| `src/daemon/paging.rs` | NEW. `read_to_end` — walk a channel with a `before` cursor until a short page; a cursor that fails to move backward stops the walk rather than looping |
| `src/daemon/run.rs` | `bot_messages`, `human_messages`, `thread_starter_messages` all read to the end. The window class leaves the adapter entirely rather than being fixed at one of its three sites |
| `src/daemon/provision.rs` | `DiscordPort` doc contract: "Recent" → every message, newest first |
| `src/daemon/reconcile.rs` | `keep` selects the OLDEST post in the wanted thread |
| `CONTEXT.md` | the board scan reads a thread whole; the survivor of a duplicate set is the oldest |
| `docs/KNOWN-HAZARDS.md` | NEW 2.11 `REQ-HAZARD-BOARD-SCAN-WINDOW` |
| `traceable-reqs.toml` | group `w47-board-scan-window`: `REQ-HAZARD-BOARD-SCAN-WINDOW`, `REQ-SCAN-KEEP-OLDEST` |

## Deployment — LANDED, and my gating claim was wrong

**CORRECTED 2026-08-21, measured on-box after doyle's reply.** This plan first said the fix was
stuck behind the HFENDULEAM cutover: core 0.53.0, installed alchemy 0.20.0, update refused. All
three numbers were carried forward from a commune dated 2026-08-04 and stated as present fact
without being re-measured. What is actually true: **core 0.59.0** (PORTER `#205` published it
today and the node flipped hours ago), installed alchemy was **0.21.0**, and the 0.54.0 floor was
already satisfied before this release existed. doyle ran `spt adapter update alchemy` — clean,
0.21.0 → 0.22.0, Hub Daemon restarted on the new binary. Verified here: `spt adapter version
alchemy` = 0.22.0, `spt --version` = spt 0.59.0, `alchemy daemon status` = running pid 28452
v0.22.0, a pid I did not start. **The flood fix is live against ⚙spt-bs-releases.**

The standing rule is untouched and still right — a release that retires a mitigation runs its
adapter update as the FIRST command after core activation (hazard 2.10). What failed was not the
rule but the input: a node's core version and installed adapter version are *live facts*, and a
17-day-old commune is not a measurement of them. Measure before asserting, or label the assertion
as dated.

One consequence worth keeping: today's release roundup pushed **9 more** Requests to DONE on that
board (doyle's count), so the over-cap condition deepened the same day the fix shipped.

## Live proof — measured on the running daemon, 2026-08-21

Two reconcile passes over ⚙spt-bs-releases on the v0.22.0 binary (pid 28452):

| pass | `posted in Done` | `duplicate cleared` | note |
|---|---|---|---|
| 1st (the repair, ~35 min) | **0** | **778** | contents post refreshed — the thread counts had been capping at 100 |
| 2nd (same settled board) | **0** | **0** | zero relocations; only line was `#153: no state label — skipped`, a data gap on the issue, not a mutation |

778 against the 779 flood posts the log recorded over its life is the residue accounted for. The
second pass is `REQ-HAZARD-RECONCILE-IDEMPOTENT` holding on that Project for the first time —
idempotence was in the reconcile logic all along and was being broken underneath it by the read
that fed it.

The repair pass emitted nothing for 35 minutes at 0.000s CPU, which looks exactly like a wedge and
was not one: reconcile logs its touched lines when the channel pass RETURNS, so an in-flight
repair is silent by construction. Worth knowing before the next long pass gets killed on suspicion.

The serial poll loop froze every other Project's reconcile for that window and nothing durable was
lost — capture is event-driven and kept minting throughout. The revisit trigger (a slow SECOND
pass, meaning per-cycle rather than one-time cost) did not fire, so no bounded-repair Request is
filed: the condition no longer exists.

## Gate

`cargo test` green, `traceable-reqs check` green, then release per `docs/RELEASE-RUNBOOK.md`.
