# W50 — the release that claims twice, the tag that waits for a tick, the list nobody can grep

Milestone **#82** over Requests **#13**, **#77**, **#78**, **#80** — the Operator's Bag, pulled
2026-09-09 and grilled the same day (two rounds, every question settled; the rulings are
recorded in `CONTEXT.md` and the four `REQ-*` ids under `w50-claims-ticks-grep` in
`traceable-reqs.toml`). Ships as **one release, `v0.26.0`** (Operator ruling, round 1 Q3).
#83 (Board Files v2 over an adapter-hosted webservice) was split out of #13 and stays in
Backlog until the enabling spt-core release ships.

Three bodies of work, built in this order — the first heals a live wrong board on another
Project the cycle it deploys, the last is the largest surface.

---

## Part A — #78 + #77: one attribution rule, claimed once

`REQ-RELEASE-ATTRIBUTION-ONE-RULE` · `REQ-RELEASE-CLAIM-ONCE` — activate `doc`/`impl`/`unit`
when this part starts.

**What is wrong, measured.** The `release` verb (`src/verbs.rs`, `RELEASE_GRACE_SECS = 30m`,
`within_grace`) admits a close landing up to 30 minutes after publish. The daemon's card
bucketing (`src/daemon/reconcile.rs::announce_releases`) reads a strict
`(prev.published, this.published]` window with no grace at all, and its commit-ref half
has no notion of *already claimed*. Two boards lie in two ways: spt-bs-releases attributes
CONDUIT's milestone to v0.66.0 where the record says v0.65.0 (#78); spt-progress-tool's
v0.23.0 card re-lists #73 because a docs commit's subject named it (#77).

**Design (settled).**

1. One module, `src/attribution.rs` (or a `release_window` fn shared by both readers),
   owning the grace constant and the predicate *ships-in(release N, issue)*:
   `closed > prev.published && closed <= this.published + grace`. The verb and the daemon
   both call it; the constant leaves `verbs.rs`.
2. The walk over published releases is oldest-first (it already is) and carries a
   `claimed: HashSet<u64>` forward. A close inside N's grace is in `claimed` before N+1 is
   computed, so a boundary close lands on exactly one card — the **earlier** one.
3. The commit-ref half consults the same `claimed` set: a Request any earlier card listed
   (by either half) is skipped. Derived each cycle, never written to GitHub (ADR-0008
   unchanged). `commit_refs` itself is untouched: a bare subject `#N` still correlates
   without a keyword (round 1 Q6).
4. Cards recompute from truth every cycle, so both live boards heal on deploy with no State
   edit — doyle's constraint on #78, honoured by construction.

**Tests.** The existing `announce_releases` fixture (`reconcile.rs` tests ~1649) gains:
a post-publish close inside grace lands on N and not N+1; a close past grace lands on N+1;
a Request on N's card via commit refs is absent from N+1's card even when N+1's commits
name it again (the #73 shape); the verb and the daemon agree on a table of boundary cases.
`within_grace` tests move with the constant.

**Docs.** CONTEXT paragraphs already written (grill). No ADR (round 2 Q19).

## Part B — #80: io frames as an additive scan hint

`REQ-TAG-SCAN-IO-HINT` — activate `doc`/`impl`/`unit` when this part starts. **ADR-0014**
records the decision (extends ADR-0011; ADR-0010 reserved exactly this hint).

Scope is the issue's two scoping comments (2026-09-06), final trigger table:

| frame | action |
|---|---|
| `io` `USER_INPUT` | scan now (`scan:io-input`) |
| `io` `AGENT_OUTPUT`, not `mid` | scan now (`scan:io-close`) |
| `io` `AGENT_OUTPUT`, `mid` | `note_inbound` only |
| `activity` idle edge | scan once then quiesce — unchanged (ADR-0011) |
| tick | unchanged; the authority |

- `src/frames.rs`: parse `type="io"` on the relay drain — `kind`, `mid`, `seq`, `truncated`
  optional and never defaulted; body decoded amp-last; the MAC check is the drain's existing
  one. Unknown `kind` → skipped per line, never wedging the drain (today's behaviour for the
  whole frame type).
- Single-flight (round 1 Q7): a trigger arriving while a scan runs is absorbed; no debounce
  timer. The digest cursor stays the sole replay authority — the hint never reads a tag body
  from a frame (`REQ-HAZARD-TAG-SEQ-DRIFT` untouched), and never depends on a close frame
  existing (claude-spt may stream a whole turn as `mid` spans).
- A scan fired on `USER_INPUT` may see the trailing turn as `partial` (core's one-blink
  idle-seal, `REQ-DIGEST-SEAL-ON-IDLE`); it fires nothing and the tick or next hint catches
  the reclosed turn. Anchor on `input_seq` only.
- `scan.log` names the reason. CONTEXT: state that `#<` obeys the suppression grammar core
  publishes for `@<` / `;;` (already true in `tags.rs`).

**Tests.** `frames.rs`: the io shapes parse, `mid` and non-`mid` distinguished, a truncated
frame is not defaulted. Scanner: `USER_INPUT` triggers a scan; a `mid` span does not; two
triggers during one in-flight scan produce one scan; a hint on an unsealed turn fires nothing
and the next tick fires it once.

## Part C — #13: Board Files

`REQ-BOARD-FILES` — activate `doc`/`impl`/`unit` when this part starts. Every ruling below
is round 2 of the grill; the CONTEXT entry **Board Files** is the authoritative wording.

**Daemon side** (`src/daemon/board_files.rs`, called from the per-Project cycle where
`bag_index::write_project` already is — the cycle already holds every issue of the Project
from `list_issues(state=all)`):

- Dir: `<config dir>/board/<owner>/<repo>/`. Files: `backlog.md eval.md greenlit.md wip.md
  acceptance.md done.md cut.md shelved.md milestones.md`.
- Header per file: Project, State, as-of (RFC3339 Z), count, one line
  "derived view of GitHub truth — `view #N` for a body".
- One line per Request:
  `- #N <title> · <TYPE|no type> · flags: <…|—> · tags: <…|—> · milestone #M|— · updated YYYY-MM-DD · <url>`.
  `milestones.md`: `- #M <title> · <STATE> · members: #a #b …`, Active before Inactive.
- Write **only when content changed**, tmp + rename (the `bag.json` discipline), so mtime
  = last truth change. Unbounded — no cap on `done.md`.
- Only Projects the daemon serves; nothing is written for anything else.

**Shell side** (`src/runtime.rs`, beside `bags`): new verb **`board [--json]`**.

- Answer: `Board Files for <owner/repo>` · `as of <t> (the daemon's last cycle; not live)` ·
  one line per State in lifecycle order, terminal after, then `milestones` — each with
  count and **absolute path**. `--json` mirrors it.
- Two refusals by name, never an empty listing: the bound repo is not a Hub Project on this
  node; the daemon has not written Board Files yet.
- Answers from disk, no GitHub call (the shell has no IPC to the daemon — `bags` precedent).

**Vocabulary parity, same commit:** `CONTEXT.md` enumeration line gains `board`;
`manifest.toml` capability `board` with `opts`; `help` line; `process-guide shell` names
Board Files; new `[[hints]]` row — keywords `grep the issues`, `search the issues`,
`board files`, `issues as files`, `issue list` (none overlap an existing row; bare `board`
belongs to the `list` row) — text naming `board`. `tests/manifest_contract.rs` and
`tests/manifest_hints.rs` hold all of it.

**Tests.** Daemon: files render from a fixture issue set with the exact line shape;
a second cycle with unchanged truth writes nothing (mtime/tmp not touched); a changed title
rewrites exactly that State's file; `milestones.md` groups Active before Inactive. Shell:
`board` on an unbound instance refuses; on a repo with no dir names it; with no files says
"not written yet"; on a written dir lists every State with counts and absolute paths in
lifecycle order; `--json` parity; `handle_text("board")` == `handle_command`.

---

## Not in W50

- #83 Board Files v2 (webservice) — Backlog until the spt-core surface ships.
- Bag residue after a shell `milestone add` (the four stay 🎒-bagged) — Operator ruling
  round 1 Q1: ignore, it is fine as it is.
- A provenance fact written to the Request ("shipped in vX") — round 1 Q5: derived dedup
  only; revisit only when something off the board needs the fact.

## Gate

`cargo test --lib --bins --locked` green (`--target-dir target/verify`, then rm it),
`traceable-reqs check` exit 0, every activated requirement with `doc`/`impl`/`unit` evidence
tagged in the commit that satisfies it. Ship `v0.26.0` per `docs/RELEASE-RUNBOOK.md`
(bump `Cargo.toml` + `manifest.toml`, `cargo update -p alchemy --offline`; `sweep` then
`gh release create`, then `release v0.26.0`); deploy on this node is DRI authority
(`spt adapter update alchemy`, never kill a shell). Live proofs after deploy: the
spt-bs-releases v0.65.0/v0.66.0 cards re-bucket CONDUIT; the v0.23.0 card drops #73;
`board` answers with real paths on alchemy-0.

## Shipped — live proofs (2026-09-09, v0.26.0 published 03:50:35Z)

Landed as four commits on `main` (Parts A/B/C at 6688305, 806fac4, f967e5b; the bump at
3b869c5), gate green at 365 tests + `traceable-reqs check` exit 0. Deployed on this node
through `spt adapter update alchemy` (0.25.0 → 0.26.0, daemon-coordinated apply); the
Hub Daemon came back on the new image without a kill — pid 32964 started 20:51:26 local
against an exe written 20:49:09, `daemon.info` reporting 0.26.0, service `held: false`,
`latch: none`. Both of my instances relinked with `spt shell relink --force` (spt's own
switch, same canonical ids, bindings kept). `sweep` → `state #82 acceptance` →
`gh release create` → `release v0.26.0` promoted #82 #80 #78 #77 #13 to Done with the
roundup, all closes before publish.

First cycle after deploy (daemon.log, `poll loop started` at line 836827):

- **#77** — `releases ⚙spt-progress-tool: release v0.23.0 card refreshed` (836857): the
  card that re-listed #73 recomputed under the claim rule; no State edited.
- **v0.26.0** — `release v0.26.0 announced (5 shipped)` (836858).
- **#13** — nine Board Files written for spt-progress-tool (836859–836867), then for every
  other served Project in turn. On disk: `done.md` count 58 with the exact line shape
  (`- #13 idea: per-repo living files … · FEATURE · flags: — · tags: — · milestone #82 ·
  updated 2026-09-09 · https://…/issues/13`), `milestones.md` count 9, Active first.
  `board` on the relinked alchemy-0 answered BEFORE the cycle with the named refusal
  ("has not written Board Files for BigscreenVR/spt-progress-tool on this node yet") and
  AFTER it with the listing — `as of 2026-09-09T03:52:22Z`, nine absolute paths under
  `C:\Users\decid\.alchemy\board\BigscreenVR\spt-progress-tool\`, counts 6/2/4/0/0/58/3/0/9.
  The new `board files` keyword hint fired on this node from the installed manifest.
- **#80** — the hint is armed on both relinked shells (new binary); its latency proof is
  the next tag an owner writes, observable in `scan.log` as `scan:io-input` under
  `scan-log.on`.
- **#78** — `releases ⚙spt-bs-releases: release v0.65.0 card refreshed` (836911) and
  `release v0.66.0 card refreshed` (836912), back to back in the same cycle: the CONDUIT
  closes that landed a minute after v0.65.0 published moved from the v0.66.0 card to the
  v0.65.0 card under the shared grace rule, and v0.66.0 dropped them under the claim. No
  State edited on any of the 289 issues; the boards healed on deploy exactly as the
  CONTEXT paragraph promised (baseline before deploy: v0.65.0 `announced (0 shipped)`,
  v0.66.0 `announced (5 shipped)`). Board Files for spt-bs-releases followed in the same
  cycle (`done.md` 150 lines).

Not done in W50, unchanged from the plan: #83 (Board Files v2 over a webservice) stays
in Backlog; the bag residue stays as ruled.

