# W41 — release reads a lagging label index (Milestone #61): #60

Self-minted from measured friction: the first `release <tag>` after a `sweep` no-ops, then
an identical re-run seconds later promotes the full set. Three for three this session
(v0.15.0, v0.16.0, v0.17.0), so it is systematic, not a flake.

## The finding that reframes it

W39 already looked at this failure once. The v0.15.0 no-op was diagnosed as a **cut-ordering
race** — a sweep landing seconds after publish, so the closes post-dated `publishedAt` — and
`REQ-RELEASE-SWEEP-GRACE` (#50) shipped a 30-minute grace window for it.

The no-op then recurred on v0.16.0 and v0.17.0 **with grace live**. So grace was not the fix
for this: it fixed a real and separate ordering hazard, but the thing making the first run
no-op was never the timestamps. It is the label read.

That matters beyond this Request: the grace window is a *widening* of an acceptance test that
is applied only to candidates that survive the label filter. A fix downstream of the actual
staleness can never repair it, and its presence made the recurrence look like a new bug each
time rather than the same one.

## Root cause

`release` asks GitHub to filter by label server-side (`src/verbs.rs:1391`):

```rust
&[("state", "closed"), ("labels", &acceptance)]
```

`labels=` is served from GitHub's label index, which lags a label write by seconds. `sweep` is
the write that puts `state: ACCEPTANCE` on exactly those Requests, so `release` is the only
step in the flow reading back what the immediately preceding step just wrote — through the one
surface that is not read-your-writes consistent.

`sweep` is immune for precisely the reason `release` is not: it lists closed issues with **no**
`labels` param and matches the state label client-side (`src/verbs.rs:1327-1341`). The two
verbs are asymmetric and the asymmetric one is the broken one.

Every other input to the decision is settled and immutable by then: `published_at` is fixed at
publish, `closed_at` is fixed at close. The label-filtered list is the sole time-dependent read
in the verb, which is why waiting and re-running is a total repair.

## Why it is worth fixing rather than living with

The failure is silent and well-formed:

> release <owner/repo> <name>: nothing to promote — no acceptance Requests closed before <ts> (+30m grace)

That is indistinguishable from the legitimate empty result. It is the shape `CONTEXT.md`
already forbids on the board surface (**`bags` verb**):

> An index the daemon has not written yet is said so by name, never rendered as an empty board.

The same principle binds here. An answer derived from an index that has not caught up must not
be rendered as a settled negative.

## Scope and design choices

Two policies, because the two call-site shapes fail differently.

- **#60 / `release` — always read unfiltered.** Drop the `labels` param, list closed issues,
  match `state: ACCEPTANCE` client-side. This is *set* semantics: partial lag (3 labels
  written, 1 indexed) would silently promote a subset and report it as the whole roundup —
  which is worse than the no-op, because it looks like success. Confirming only the empty case
  would not catch that, so the read is unconditional. Cost is one unfiltered closed-issue page
  walk, exactly what `sweep` already pays in the same command sequence.

- **`find_masterlist` (`src/watch.rs:201`) — confirm the negative.** Same server-side filter,
  and the miss is worse than a no-op: `ensure_masterlist` is find-or-create, so a masterlist
  minted moments earlier that has not indexed yet gets a **second** masterlist minted on top of
  it. The source comment ("lowest number wins if a crash ever minted two") already treats
  duplicate mint as reachable; this is a live path to it that needs no crash. Here a hit is
  trustworthy — the lookup is a singleton, not a set — so only the **empty** result is re-read
  unfiltered before minting. Common path pays nothing.

- **Milestone pickers (`src/daemon/run.rs:1917`, `:2055`) — confirm the negative.** A Milestone
  minted and then immediately picked in Discord can be missing from the select, and an empty
  pickable list is rendered to the user as "none available". Same forbidden shape, lower blast
  radius (self-corrects next cycle, user can retry), same one-line remedy. "Is there any" is
  again a question whose *no* is the untrustworthy half.

Helpers land on `GithubClient` so the policy is named where the hazard is, not restated at four
call sites:

- `list_issues_by_label_settled(owner, repo, query, label)` — unfiltered read, client-side match.
- `list_issues_by_label_confirmed(owner, repo, query, label)` — filtered read, re-read unfiltered
  when and only when it comes back empty.

Not in scope: a general retry/sleep/poll anywhere. The lag is designed-around, not waited out.

## Requirement + hazard

- New `REQ-RELEASE-LABEL-INDEX-LAG` (`impl`, `unit`) — the fix.
- New hazard **2.9** `REQ-HAZARD-LABEL-INDEX-EMPTY` (`unit`) in `docs/KNOWN-HAZARDS.md` — the
  standing invariant: a label-filtered GitHub read must never be rendered as a settled negative.
  This is the generalization; it is what stops the fourth call site from being written wrong.

## Gate

`cargo test` + `traceable-reqs check` green → RELEASE-RUNBOOK v0.17.1 (close pre-publish, tag,
publish, `spt adapter update`, then `sweep` + `release v0.17.1`).

**The release itself is the live proof.** If the first `release v0.17.1` after its `sweep`
promotes without a re-run, the fix is demonstrated on the exact surface that produced 3/3
failures. A green suite cannot show that.
