# Known Hazards

> A **conformance checklist, not advice.** Each hazard below is a first-class
> `REQ-HAZARD-*` requirement in `traceable-reqs.toml`, and is **not "covered" until a test tags
> it** (`unit`, plus `int` where the failure is cross-process / cross-node). This file exists to
> make "we won't re-break X" mechanical: an entry without a passing tagged test is an open risk,
> and `traceable-reqs check` will say so once the hazard is activated.

A hazard earns a place here when it is an invariant you have *paid for once* (a real bug, an
incident) or one you have *committed never to introduce*. State it so a test can prove it.

## Entry format

Each entry is one numbered subsection with these fields:

- **Failure** — the concrete bad behavior: what goes wrong, under what sequence / timing / input.
- **Invariant** — the property that MUST hold, phrased so a test can assert it (the thing the
  `REQ-HAZARD-*` requires).
- **Mapping / notes** — where this lives in *this* project, and anything that changes the shape
  of the test (e.g. "in-process now, so use a lock instead of racing on disk").
- **cite** — where the failure / fix is evidenced (an incident, a prior commit, a source path);
  reference only — the binding evidence is the tagged test.

Mirror each entry as a requirement:

```toml
[[requirements]]
id = "REQ-HAZARD-EXAMPLE"
title = "The invariant, stated so a test can prove it"
required_stages = []   # activate (["unit"] or ["unit","int"]) when you cover it
```

---

## 1. Process & lifecycle

### 1.1 Double daemon on one node — `REQ-HAZARD-DOUBLE-DAEMON`

- **Failure:** Two starts race (originally two shell instances racing auto-ensure; since W25, a
  supervisor start meeting a daemon that is already up); two daemons hold gateway connections
  and every Discord write happens twice — double Bot posts, double mints, double notifications.
- **Invariant:** With a live daemon holding the node lock, a second spawn attempt on that node
  exits without ever connecting to the Discord gateway.
- **Mapping / notes:** OS-level lock (locked lockfile, released by the OS on process death) taken
  before gateway connect in daemon startup — the yield lives in the daemon process itself, not in
  whatever started it, which is why the invariant survived alchemy's start path being deleted
  wholesale (W25). Singleton is **per node** by design — cross-node duplication requires
  deliberately configuring two nodes identically and is not defended. Test: spawn two, assert
  exactly one connects (`unit` on the lock; `int` on a real `daemon run` child).
  **Adoption note (2026-07-27):** this correct yield — an immediate exit 0 — is read by
  spt-core's supervisor as a startup fault, which latches supervision off on the first boot
  after an adapter with a pre-existing start path declares `[service]`. Not alchemy's defect
  and not a reason to weaken the yield; reported and accepted by doyle as an adoption-path
  finding, banked for ranking. Interim remedy: `spt adapter add <dir>` clears the latch.
- **cite:** Phase 3 grill ruling (2026-07-21); designed-in commitment, no incident.

### 1.2 Cross-owner instance-state clobber — `REQ-HAZARD-CONFIG-CROSS-OWNER`

- **Failure:** Shell instance ids are canonical **per owner** (`alchemy-0` for flynn and
  `alchemy-0` for lia are different instances), but instance state files keyed by bare id in
  the shared node dir (`~/.alchemy/<id>.toml`, `<id>.tags.toml`) collide the moment a second
  owner on the node mints the same slot: the second bind silently overwrites the first
  owner's bound repo, and a restarted shell resumes bound to **another owner's Project** —
  writes land in the wrong repo. Live incident 2026-07-23: lia's spt-progress-tool bind
  clobbered flynn's alchemy-testbed bind during W16 bringup.
- **Invariant:** One instance's persisted state (bind config, tag cursor) is never read or
  written by another instance, regardless of id collision across owners on one node.
- **Mapping / notes:** Instance state roots in the instance's **perch dir** (`{perch_dir}`
  spawn key) — per-instance by construction, dies with the instance — falling back to the
  shared node dir only when spawned without `--perch` (pre-0.39.2 spt). Node-global daemon
  state stays in the shared dir on purpose. Consequence accepted: teardown now forgets the
  bind; a respawn re-runs `bind-repo`. Test: two same-id runtimes on distinct state roots,
  assert full isolation both directions.
- **cite:** Live incident during W16 bringup (2026-07-23), this file's first entry paid for
  in the field rather than designed-in.

### 1.3 Tag double-fire on forward seq drift — `REQ-HAZARD-TAG-SEQ-DRIFT`

- **Failure:** The tag scanner has no dedupe store — the persisted cursor *is* the dedupe — so
  the cursor must key on something that never moves. An **entry's** `seq` moves: spt-core may
  advance entry seqs forward inside a turn when a late straggler record grows a trailing
  tool-sprint, and a forward move re-delivers that entry to an `--after` cursor (it never gaps
  it). A cursor anchored on the highest entry `seq` therefore sees an already-fired entry come
  back past it and fires the tag **again** — on a `create` tag that is a duplicate Request
  minted from one submission, and the submitter is told about only one of them.
- **Invariant:** A tag fires at most once even though an entry's seq may advance forward within
  its turn: the cursor anchors on the **turn's `input_seq`** — the key that never moves once a
  turn closes — and never on entry seqs. A partial turn (which carries no `input_seq` at all)
  neither fires nor advances the cursor.
- **Mapping / notes:** `scan_digest` walks turns, skips any turn flagged `partial` / missing
  `input_seq`, fires every live tag in the sealed turns past the cursor, and advances the cursor
  over sealed `input_seq` only. This also makes spt-core's documented transient — a sealed
  trailing turn can briefly read `partial` again if the owner goes busy before the next input
  record is extracted, then recloses with the *same* seqs — a no-op: a cursor already past that
  turn skips it, and a cursor behind it just polls again. Baseline semantics are unchanged
  (history never replays), and the arming turn still fires when it seals, because it is partial
  at arm time and so contributes no `input_seq` to baseline on. Test: replay a snapshot whose
  entry seqs advanced forward, assert nothing re-fires.
- **cite:** spt-core consumer facts from doyle (2026-07-25) alongside the idle-seals ruling
  (ADR-0048 decision 4); caught by inspection before it fired in the field.

### 1.3a A silently jumped turn — `REQ-TAG-CURSOR-JUMP-ANNOUNCE`

> The one entry here whose binding requirement is **not** a `REQ-HAZARD-*`. The behavior
> requirement already exists and already carries the test, and minting a hazard id beside it
> would be two ids for one property — the confusability the requirement audit exists to
> prevent. It is listed here because it is a **tripwire**: the shape is unreachable under the
> published turn contract, so this entry is what makes the guard survive the memory of why it
> was written.

- **Failure:** The cursor keys on the turn's `input_seq`, so a turn that carries none cannot
  fire and cannot be keyed. Two very different turns land in that branch. A **preamble** turn
  (`input: null`, where Boundary/Context entries live) is seq-less by design and tagless by
  construction — jumping it is correct, and *holding* on it wedges the cursor at every session
  boot. An **input-bearing** turn with no `input_seq` is a turn that could have carried a tag.
  Treating the two the same means the second is advanced past in silence, and the tag written
  in it is simply gone — the owner is never told, and the shell looks like it is working.
- **Invariant:** When the scanner advances its cursor past a **non-partial, seq-less,
  input-bearing** turn, it announces the jump to the owner naming what was skipped. A preamble
  turn is jumped silently and the cursor still advances. The announce fires on the **advance**,
  not on the sighting: a seq-less turn at the tip is not a loss yet (a turn sealing after it
  would fire it normally), and a seq-less turn behind an already-consumed one was announced on
  an earlier scan — it has no seq to compare against the cursor, so without both suppressions
  one lost turn re-announces on every scan until the window drops it.
- **Mapping / notes:** `scan_digest` splits today's seq-less `continue` on the turn's `input`
  and records `DigestScan::jumped`; `TagPump::tick` turns each into a `Notice` carried
  `Delivery::ActiveOnly` — durable, so it is never dropped, but riding the owner's own poll
  rather than waking them for a diagnostic (Operator ruling 2026-07-28). Announce-and-advance is
  the **terminal** shape, not an interim: a barrier would need a will-seal vs never-seals
  discrimination, which is a negative over the future (doyle's ruling 2026-07-26). Test against
  a **rig-constructed** snapshot only — the shape cannot be produced on a live node, and no
  specimen is to be manufactured on a live endpoint.
- **cite:** Request #26; design ratified 2026-07-26 with liam's census as the discriminator;
  delivery class ruled 2026-07-28. Caught by inspection, never seen in the field.

### 1.4 A gated scanner going deaf — `REQ-HAZARD-SCAN-GATE-DEAF`

- **Failure:** Owner activity gates the digest poll (ADR-0011), so the scanner quiesces when its
  owner goes idle and waits for a busy frame to resume. Activity frames are drive-class —
  never spooled, never replayed, dropped outright under a broken link. If the frame that would
  re-enable the poll never arrives, the scanner is not slow, it is **permanently deaf**: every
  tag the owner writes from then on is lost rather than delayed, silently, with the shell still
  reporting online. Gating a must-not-miss read on a best-effort signal is the trade this
  hazard exists to refuse.
- **Invariant:** A lost activity frame costs latency, never silence. A quiesced scanner still
  scans on a heartbeat floor; any inbound owner frame resumes it immediately; and a shell that
  has never seen an activity frame never quiesces at all. No single dropped message — and no
  run of them — can gate the poll off indefinitely.
- **Mapping / notes:** `src/scan_gate.rs` holds the state machine, transport-free like
  `TagPump`, so each floor is asserted without a daemon. Three floors, deliberately
  independent: any one alone is sufficient, which is the point — the heartbeat needs no frames,
  the inbound resume needs no frame *delivery*, and fail-open covers an spt-core older than
  0.42.0 or a drain that answers nothing. Note the related read-side trap: the drain is
  latest-wins, so a busy→idle round trip between two reads collapses to one idle frame and
  edges must be derived from `since`, not from the state word — otherwise the gate stays
  quiesced across a turn that really did seal. Tests: quiesce, then withhold the busy frame and
  assert the heartbeat keeps firing; assert an inbound frame resumes; assert a gate that has
  seen no frame never quiesces.
- **cite:** ADR-0011 constraint 2 (2026-07-25) — committed never to introduce, built under the
  constraint rather than paid for in the field. The failure it generalizes *was* paid for: the
  mint-then-idle deadlock that cost a Liaison bringup, which is the same shape one layer down.

### 1.5 The Hub Daemon dies with its parent shell, silently — `REQ-HAZARD-DAEMON-ORPHAN-DEATH`

- **Failure:** The Hub Daemon is spawned detached by whichever shell instance happened to
  auto-ensure it, which makes it that shell's **child process**. spt-core's force-close
  (`close_shell`, the path `shell teardown` and the owner-suspend cascade run) is
  `taskkill /PID <pid> /T /F` on Windows — a **tree** kill — so tearing down that shell
  takes the daemon with it. Nothing restarts it: `ensure` runs only at a shell's bind, so
  the service stays down until some shell happens to bind again, which may be hours or
  never. **Measured on this node 2026-07-25:** daemon pid went to zero within 0.1s of a
  teardown and stayed zero across a 15s watch with nothing binding; recovery required a
  manual `spawn` + `bind-repo`. The Unix arm is a single-pid `kill -9`, so the behavior
  also differs by platform.
  The blast radius is not the Discord surface alone — the daemon also runs the **Watch
  engine**, so every registered Watch for every owner silently stops delivering. An agent
  waiting on a Request to reach Done simply never hears, with no error anywhere. That is
  `REQ-HAZARD-WATCH-SILENT-LOSS` passing to the letter (the registry entry survives)
  while its intent fails (nothing is left to deliver it).
- **Invariant:** The Hub Daemon's lifetime is never a side effect of an individual
  shell's lifetime, and its absence is never silent: either the service outlives any
  shell by construction, or its death is detected and reported to the Operator rather
  than waiting for the next bind to notice.
- **Mapping / notes:** Not fixable inside alchemy alone — `close_shell`'s tree kill is
  spt-core's. **The target shape is already ratified upstream:** REQ-EP-8 / ADR-0023's
  **AlwaysOnEndpoint** — a resident, addressable, mindless endpoint whose binary the
  *daemon* supervises continuously, declared by an `[always-on]` manifest section and
  running independent of any agent's liveness. ADR-0023 explicitly rejected "make it a
  Shell" for the reason this hazard demonstrates, and its named first consumer is a
  Discord bridge. So the Hub Daemon is not an edge case the design must accommodate; it
  is the case the design was written for, and the open question is *scheduling* that
  milestone, not architecture. Until it lands, doyle's
  `REQ-SHELL-ADAPTER-OWNED-DETACHED-SERVICE` records the interim option space, tightened
  to: a manifest tree-kill exemption is defensible **only as explicit interim toward
  REQ-EP-8**, never as the answer, since making it permanent re-adopts the shape ADR-0023
  already rejected.
  **Mitigation available today, no core change:** the tree kill can only reach a process
  a shell parents, so starting the Hub Daemon **out-of-band** (a scheduled task, a
  service, any parent whose lifetime is the node) removes it from every shell's tree and
  closes the node-wide Watch outage outright. The trade is that nothing restarts it on
  crash — but that supervision is illusory today anyway: the daemon is not supervised, it
  merely happens to be started at bind and killed at teardown. Unsupervised-but-safe
  strictly beats supervised-by-accident-and-killed-by-design.
  **Root cause, corrected 2026-07-25 — the defect was alchemy's own spawn** (ADR-0013).
  The Operator asked the question that settles it: how can a force-close kill a *detached*
  process it never spawned and does not know about? It cannot, and it did not need to.
  `taskkill /T` enumerates descendants by **ParentProcessId at kill time**, and
  `CREATE_NO_WINDOW` / `CREATE_NEW_PROCESS_GROUP` / null stdio detach the console and the
  signal group but **never the parentage**. A daemon spawned straight from a shell is
  still that shell's child in the process table, so the tree walk reaches it — the
  daemon was never as detached as `CONTEXT.md` said it was.
  Measured, isolated A/B on this node: a directly-spawned child dies with the tree; a
  grandchild whose intermediate has already exited survives it. **Fixed by spawning
  through one relay hop** — the binary re-invokes itself with a hidden op, that process
  spawns the daemon and exits, and the daemon is left with a dead ParentProcessId no tree
  walk can reach it through. Verified with the real binary: parent dead, `taskkill /T` on
  the stand-in shell, daemon still alive. This adds **no second start path** — the start
  still rides whichever endpoint's linked alchemy shell comes up first (Operator ruling:
  no scheduled task, no autostart mechanism; ADR-0012 Rejected).
  What remains upstream is smaller and no longer blocking: spt-core's force-close is a
  *tree* kill on Windows and a single-pid kill on Unix, so it can still reach detached
  descendants of other adapters that have not done this. REQ-EP-8 / ADR-0023
  (AlwaysOnEndpoint) remains the ratified target shape for a resident service.
  <!-- [doc->REQ-HAZARD-DAEMON-ORPHAN-DEATH] -->
  Left at `required_stages = []` deliberately: the invariant's test shape depends on which
  interim is chosen and on when REQ-EP-8 lands, and activating it now would pre-fail a
  gate on scheduling that is not alchemy's to set. Tracked as Request #22.
- **SETTLED at W25 (spt-core v0.44.0), and the interim is gone.** The daemon is now a
  **ResidentService** declared in `[service]` and supervised by spt's own daemon —
  spawned job-neutrally, core-owned from birth. The invariant holds **by construction**:
  the daemon is no shell's child (nothing for a tree walk to reach) and is outside the
  launching terminal's Job Object (nothing for a job close to sweep), which is the half
  the relay hop never covered. Alchemy's start-path count went from one to **zero** —
  `spawn_relay`, `spawn_detached`, auto-ensure-at-bind and the `ensure`/`restart`/`stop`
  ops are all deleted. Tracked as `REQ-DAEMON-SUPERVISED` (doc+impl+unit).
  **The one way to silently un-fix this** is `start = "bind"` in place of `"boot"`: it
  parses, it supervises identically once running, and it quietly restores the
  "whichever shell binds next starts it" dependency this whole hazard is about. A
  contract test pins `"boot"` for exactly that reason.
  <!-- [doc->REQ-DAEMON-SUPERVISED] -->
- **cite:** doyle's code reading of `shellhost::kill_shell_pid` (2026-07-25) plus the
  teardown experiment above. The assumption it falsifies — "a detached daemon survives its
  parent shell's link-break" — was mine, load-bearing, and never tested until now.

## 2. Sync & reconciliation

### 2.1 Reconcile is not idempotent — `REQ-HAZARD-RECONCILE-IDEMPOTENT`

- **Failure:** A reconcile cycle rewrites or relocates Bot posts when nothing changed upstream
  (e.g. re-observing its own button-write), churning message ids and spamming the Updates thread
  every polling interval.
- **Invariant:** A reconcile cycle over an already-consistent Project performs zero Discord
  mutations.
- **Mapping / notes:** The reconcile diff in the daemon; buttons write GitHub only, so the echo
  path is exactly "reconcile re-observes". Test with a fake Discord port recording mutations.
- **cite:** ADR-0008; same idempotence bar the Phase 2 `sweep`/`release` verbs already meet.

### 2.2 Auto-archived thread drops a write — `REQ-HAZARD-THREAD-ARCHIVE`

- **Failure:** Discord auto-archives idle threads; a relocation or notification targeting an
  archived State/Updates/Releases thread fails or silently loses the post, and the boot scan
  misses posts in archived threads (index holes → duplicates).
- **Invariant:** A daemon write targeting an auto-archived thread unarchives it first; no write
  is dropped or errors out because of archival. Boot scan covers archived daemon-owned threads.
- **Mapping / notes:** Thread-ensure wrapper around every thread write + the boot scan in the
  daemon. Test: fake port reporting archived state, assert unarchive-then-write ordering.
- **cite:** Phase 3 grill (2026-07-21); Discord auto-archive is platform behavior, guaranteed
  to occur on quiet Projects.

### 2.3 Silently lost Watch — `REQ-HAZARD-WATCH-SILENT-LOSS`

- **Failure:** A Watch disappears without ever notifying its owner: a registry write race
  erases a fresh comment, a crash between fire and ack drops the delivery, or an engine
  restart forgets in-memory-only state. The waiting agent blocks forever on a cleared blocker.
- **Invariant:** Every registered Watch either delivers at least one notification for a
  matching change or remains in the registry. Fire order is deliver-then-delete, so a crash
  between the two re-fires (at-least-once) rather than losing the Watch; a re-fire is
  tolerated, a silent loss never is.
- **Mapping / notes:** Comment-per-Watch makes registration atomic (ADR-0009); the engine's
  in-memory registry is a cache of the masterlist comments, rebuilt on boot. Test: fake
  GitHub port; crash the engine between deliver and delete, assert the next cycle re-fires.
- **cite:** ADR-0002 (durability is the point of Watches); ADR-0009 considered-options race.

### 2.4 Milestone cascade tramples terminal children — `REQ-HAZARD-CASCADE-TERMINAL`

- **Failure:** Advancing a Milestone (Greenlit/WIP/Acceptance) drags a Cut, Shelved, or
  already-Done sub-issue back into a live State — silently resurrecting declined or
  finished work under a batch operation nobody reviews per-child.
- **Invariant:** A Milestone State cascade never mutates a child in Done, Cut, or Shelved,
  and a cascade to Done never happens at all (Done is earned per-Request).
- **Mapping / notes:** Cascade filter in the milestone state path. Test: milestone with
  mixed-state children, assert terminal children's labels untouched and no Done cascade.
- **cite:** Phase 4 grill ruling 4 (2026-07-23); designed-in commitment, no incident.

### 2.5 Stripped ref field orphans a Bot post — `REQ-HAZARD-REF-FIELD`

- **Failure:** An edit or re-render of a Bot post drops the machine-parseable Request ref;
  the boot scan can no longer map post → issue, and the next reconcile posts a duplicate for
  the "missing" Request.
- **Invariant:** Every Bot post mutation preserves the ref field, and the boot scan surfaces an
  unmapped bot-authored post in a daemon-owned channel loudly instead of silently duplicating.
- **Mapping / notes:** Single render path for Bot post embeds (ref field non-optional in the
  type); scan-side assert. The ref field is load-bearing per ADR-0008 — Discord IS the index.
- **cite:** ADR-0008 consequences section.

### 2.6 A renamed Project channel forks into two — `REQ-HAZARD-CHANNEL-ADOPT`

- **Failure:** The generated channel name changes (Request #2: `_repo` → `⚙repo`) and
  provisioning, which finds a channel by exact name, sees none — so it creates a second
  channel for the same Project. The Project's entire Discord history (its State threads, its
  Seeds, its pinned Contents post) is stranded in an orphan the daemon no longer serves, and
  the per-Project Alchemist role, whose name is derived from the channel name, stops matching
  — every member silently loses write capability.
- **Invariant:** A Project never ends up with two channels. A channel already provisioned for
  it under a superseded name is adopted and renamed in place, and no Alchemist loses write
  capability across the rename.
- **Mapping / notes:** Provisioning resolves a channel by current name, then by any legacy
  name, before it creates one; the Alchemist role name is keyed to a stable identifier derived
  from the repo, not to the display name — so a rename cannot move the string capability is
  held under. Test: a guild holding the legacy channel gets no second channel, keeps its
  thread ids, and its role name is unchanged.
- **cite:** W28-PLAN.md group D (Request #2); the name-derived mapping in ADR-0008's index.

### 2.7 Relocation empties a Bag — `REQ-HAZARD-TARGET-RELOCATION-CARRY`

- **Failure:** A bagged Request is a reaction on a Bot post, and relocation between State threads
  is a **new message plus a delete** (ruling 10) — reactions do not move, they die with the
  message they were on. So a Request that changes State mid-assembly silently leaves every
  Bag holding it, and the Alchemist who bagged it finds out when the Milestone is
  finalized without it.
- **Invariant:** A relocation **within** the baggable zone (Backlog ↔ Eval) carries the
  Bag entries onto the destination card in the same operation that posts it — before the old
  message is deleted, never after, so a crash between the two cannot lose them. A relocation
  **out** of that zone drops them and posts one Updates note naming the Request and the
  Alchemists whose Bag it left; it is never silent. A settled board carries nothing and
  writes nothing.
- **Mapping / notes:** The carry cannot be the reactions themselves — **a bot may only create a
  reaction as itself**: serenity 0.12.5's `Http::create_reaction` takes no user id and routes to
  `PUT …/reactions/{emoji}/@me`, and there is no add-as-user endpoint on the platform. Removal
  as another user *is* available (`Http::delete_reaction(channel, message, user_id, emoji)`,
  `MANAGE_MESSAGES`), which is the asymmetry the design rests on: the store can be cleared on a
  member's behalf, never restored. So the carry is a machine-readable `🎒 carried:` line on the
  destination card and a Bag is the **union** of a card's live reactors and that line. The
  daemon still remembers nothing between cycles — Discord holds all of it either way; only which
  field does changes after a move. Because a carried Bag entry has no reaction to take back,
  reaction + carried for the same Alchemist is the **un-bag** gesture and the bot clears both.
  A roster the daemon cannot READ (the member-listing endpoint is gated on the Server Members
  Intent) is not an empty roster: that cycle carries every card's line forward verbatim, judges
  nothing and removes nothing — otherwise the drift correction itself becomes what empties the
  Bags. Test: a card carrying two Alchemists' Bag entries relocates and the destination reads the same two;
  a second cycle over the result mutates nothing; a move to Greenlit drops them with a note.
- **cite:** Request #10 (W34-PLAN.md); `CONTEXT.md` **Bag**; the platform constraint measured
  in the linked client, the same way `M38-PLAN.md`'s select-in-modal constraint was.

### 2.8 A vocabulary rename empties every live Bag — `REQ-HAZARD-BAG-EMOJI-MIGRATION`

<!-- [doc->REQ-HAZARD-BAG-EMOJI-MIGRATION] -->

- **Failure:** The Bag reaction's emoji is part of the STORE, not part of the render: Discord
  holds the Bags and the daemon remembers nothing between cycles. So renaming it (🎯 → 🎒,
  Operator ruling 2026-07-29) with a one-line constant swap makes the next cycle read an emoji
  nobody has reacted with, find nothing, and treat every live Bag as emptied — silently, and by
  the drift correction itself. Four Bags were live when the ruling landed.
- **Invariant:** A member's reaction under the **retired** emoji is folded into the card's
  `🎒 carried:` line — the one Bag field the daemon may write on a member's behalf — and only
  **then** is that reaction removed and the new emoji seeded. Card first, reaction second: a
  crash between them leaves the Request bagged twice over, never dropped. A card carrying no
  retired mark asks for nothing, so the fold is idempotent and a settled board stays at zero
  mutations. A non-Alchemist's retired reaction is left exactly where it is — inert then, inert
  now, and never removed.
- **Mapping / notes:** The fold is not a convenience, it is the only move available: a bot may
  only create a reaction **as itself** (`create_reaction` → `PUT …/reactions/{emoji}/@me`), so a
  member's 🎯 can never be re-placed as 🎒 — the same asymmetry 2.7 rests on, met a second time.
  It runs inside the reconcile's existing card read, so it costs no extra call, and it obeys the
  same roster rule: a roster that could not be READ is not an empty roster, and that cycle folds
  nothing. Test: a live 🎯 Bag survives the rename as a carried Bag with the 🎯 removed; a second
  pass writes nothing; a non-Alchemist's 🎯 is untouched; the bot's own stale seed is cleared.
- **cite:** Operator ruling 2026-07-29 (`W36-PLAN.md`); `CONTEXT.md` **Bag**; hazard 2.7 for the
  platform asymmetry this reuses.

### 2.9 A decision taken on a lagging label index — `REQ-HAZARD-LABEL-INDEX-EMPTY`

<!-- [doc->REQ-HAZARD-LABEL-INDEX-EMPTY] -->

- **Failure:** GitHub serves `GET /issues?labels=` from an index that lags a label write by
  seconds. Any read that filters server-side on a label a preceding step just wrote can come
  back short — or empty — while every issue involved is already correct at the source, and the
  empty answer is indistinguishable from a real one. Measured on `release`: the first
  `release <tag>` after a `sweep` promoted nothing on v0.15.0, v0.16.0 and v0.17.0, each time
  answering `nothing to promote — no acceptance Requests closed before <ts>`, each time fixed by
  re-running the identical command seconds later. The same read shape reaches worse outcomes
  elsewhere: `ensure_masterlist` mints on miss, so a lagging lookup mints a **second**
  masterlist over a live one, and an empty Milestone picker tells the Operator there is nothing
  to join moments after they minted the Milestone.
- **Invariant:** A negative from the label index is never acted on unconfirmed. Either the read
  does not use the server-side filter at all (walk the list, match locally), or its **empty**
  result is re-read without the filter before it is treated as settled. Where the answer is a
  *set* whose partial read would be reported as the whole — the release roundup — the unfiltered
  read is unconditional, because a short read is non-empty and would sail past a confirm-on-empty
  check while looking like success.
- **Mapping / notes:** `GithubClient::list_issues_by_label_settled` (unfiltered + local match)
  and `::list_issues_by_label_confirmed` (filtered, re-read only when empty) name the two
  policies; `release` takes the first, `find_masterlist` and both Milestone pickers the second.
  `sweep` was always immune — it never passed `labels` — and that asymmetry with `release` is
  what the fix removes. **Note the mis-diagnosis this cost:** W39 read the v0.15.0 no-op as a
  cut-ordering race and shipped `REQ-RELEASE-SWEEP-GRACE` for it. Grace fixed a real and
  separate hazard, but it widens an acceptance test applied only to candidates that already
  survived the label filter — a repair downstream of the staleness cannot reach it, and its
  presence made each recurrence look like a fresh bug. No retry, sleep or poll is any part of
  the remedy. Test: a label-filtered list serving empty while the unfiltered list carries the
  labelled issues — assert `release` still promotes the full roundup, `find_masterlist` finds
  the existing masterlist rather than minting a second, and a settled board mutates nothing.
- **cite:** Request #60 (`W41-PLAN.md`); `CONTEXT.md` **`bags` verb** — "An index the daemon has
  not written yet is said so by name, never rendered as an empty board" — the same principle,
  which this hazard extends from the daemon's own index to GitHub's.

### 2.10 A mitigation that outlives the defect it mitigates — `REQ-HAZARD-MITIGATION-RETIREMENT-COUPLING`

<!-- [doc->REQ-HAZARD-MITIGATION-RETIREMENT-COUPLING] -->

- **Failure:** alchemy shipped a mitigation for an spt-core defect (#52: within one `send`
  invocation the text frame drained before the file frame, so `send "create X" --file f` minted
  X with nothing and stranded `f` against the sender's NEXT write). The mitigation was a
  15-second per-requester retro-apply window: a file frame arriving just after an
  empty-staging create was treated as that create's own stranded sibling and applied to it.
  It was correct, and it was described as staying on as belt-and-braces after the upstream
  fix, retiring on its own schedule. **That disposition was not available.** The window has no
  invocation correlation — it distinguishes "my own create's stranded sibling" from "the first
  frame of the next invocation" purely by which order the frames arrive in. Once spt-core
  0.54.0 ordered them (file before text), the identical code delivers the NEXT invocation's
  file to the PREVIOUS Request and mints the intended one empty. Two bare creates inside
  fifteen seconds is ordinary Hub use — it happened on this board the night the Request was
  filed. The upstream fix would have turned a working sequence into a silently wrong one, for
  exactly as long as the mitigation stayed installed.
- **Invariant:** A mitigation written against a known upstream defect records its **retirement
  coupling at the moment it is introduced** — naming the upstream tracker it waits on, and
  stating that adopting the fixed upstream deletes it in the **same change** that raises
  `min_spt_core_version`. Not before (the old behaviour still needs it) and not after (that gap
  is precisely when the misfire is live). A mitigation whose correctness depends on the defect
  it mitigates cannot be given a "retires on its own schedule" disposition, and cannot be left
  on as belt-and-braces: when the defect goes, the mitigation is not inert insurance, it is an
  active defect. Where a mitigation could be made ordering-independent it should be — this one
  could not without an invocation correlation key, which upstream considered and refused as
  heavier than the ordering fix itself.
- **Mapping / notes:** The coupling was recorded at the functions themselves
  (`Engine::retro_apply_file`, `Engine::retro_apply_comment_file`), in
  `REQ-COMMENT-FILE-RETRO-APPLY`'s own title, and in Request #64 — which is why the deletion was
  mechanical rather than archaeological when the version string landed. Both requirements are
  retired in `traceable-reqs.toml` with dated notes; the property they were reaching for now
  holds on the plain staging path, which is strictly stronger (the file is present when the
  write runs, so it lands in ONE write instead of an edit afterwards). Test:
  `a_file_frame_between_two_creates_lands_on_the_second_one_only` — a bare create, a file frame,
  then a second create; the file lands on the second Request and the first is never read or
  patched, which is exactly the misfire the retired window would produce under the new ordering.
- **The coupling has a DEPLOYMENT half, and spt-core does not enforce it.** Shipping the floor
  raise and the deletion in one commit makes the *source* safe. It does not make a *node* safe,
  because the two sides move independently on a node and only one direction is gated.
  `min_spt_core_version` is evaluated at the **acquisition verbs only** — `adapter add` and
  `adapter update` — and nowhere else: not at resolve, not at spawn, not at daemon start. An
  already-installed adapter is never re-judged when core moves underneath it. So the gate fires
  when the ADAPTER moves (measured on HFENDULEAM 2026-08-04: `adapter update alchemy` refused
  0.21.0 against core 0.53.0, naming the version and the remedy) and is silent when the CORE
  moves — which is the direction that inverts a correct pairing into the misfire. Concretely:
  alchemy 0.20.0 on pre-0.54.0 core is CORRECT (mitigation present, defect present); the instant
  that node's core reaches 0.54.0 and until `adapter update alchemy` runs, that same install is
  the live misfire, silently, under ordinary Hub use. **Therefore: a release that retires a
  mitigation must run its adapter update as the FIRST command after the node's core activation
  — not as a following item — because the exposure window is exactly the gap between them and
  there is no backstop.** The node's own upgrade cycle is not a backstop either: it may sweep
  adapters BEFORE the core finish, against the old version string, refusing the new adapter
  in-cycle and landing the node on the new core with the OLD adapter still installed — the
  exposed pairing precisely (measured on the HFENDULEAM cutover, filed as `releases#159`).
- **cite:** Request #64 (`W46-PLAN.md`); doyle's ruling 2026-08-01 on spt-core #81 (frame
  ordering, correlation key refused); spt-core v0.54.0 published 2026-08-04, the floor this
  hazard's fix raises alchemy to. The acquisition-only enforcement is deployah's source
  enumeration at the shipped tag `86f0d84`, 2026-08-04 (the two evaluating sites are the update
  verb's pre-swap gate and `register_with_core`'s acquisition choke point; spt-core's own
  `manifest.rs` states the floor is enforced "at both ACQUISITION VERBS (add + update)") —
  RULED-AT-SOURCE, since alchemy is clean-room and cannot verify it here. doyle ruled the same
  question independently and reached the same two sites, and adds that no ceiling field exists
  at all; two derivations agree. The question reached source rather than being assumed in
  either direction because it was handed over labelled UNVERIFIED — the measured half (the
  refusal) and the inferred half (no re-check) were sent as separate claims.

### 2.11 A windowed read of the board, reasoned from as the whole board — `REQ-HAZARD-BOARD-SCAN-WINDOW`

- **Failure:** Discord answers a channel history one page at a time, capped at 100 messages. The
  production port issued a single `GetMessages::new().limit(100)` and returned it as the channel.
  The board scan built its `number → posts` map from that, so a Bot card sitting deeper than the
  newest 100 messages of its thread was invisible, and reconcile — finding no card for a Request
  that has had one for weeks — took the create arm: a fresh card plus an Updates note announcing
  a transition that happened long ago. Each 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, and the duplicates it leaves are permanent because the originals
  it duplicated stay out of window. Terminal threads reach the cap first — nothing prunes Done.
  Measured live as Request #73 on `Done 3` of `⚙spt-bs-releases`; at diagnosis that Project
  carried **117** Requests labelled `state: DONE` against 55, 19 and 0 for the other three, so
  the cap boundary predicts exactly which channel floods.
- **Invariant:** Every channel history read the daemon reasons from returns the channel's WHOLE
  history, walked to its end, or fails. A partial history is never returned as a complete one,
  and no card-create decision is ever taken on a page. Corollary: a test double for the port must
  not be MORE capable than the port — the double here returned every message in the channel, so
  the window it stands for did not exist anywhere in the suite and no test could have caught this.
- **Mapping / notes:** The walk is `daemon::paging::read_to_end` — cursor by the oldest snowflake
  of each page, stop on a short page, an empty page, or a cursor that fails to move backward
  (a page that repeats is discarded, not appended). `SerenityPort::all_messages` walks it and
  all three history readers go through it: `bot_messages` (the board scan, the Seeds mirror, and
  the Contents post's per-thread counts, which had been capping at 100), `human_messages` (Seed
  recovery — an un-promoted Seed older than the newest page is still a Seed), and
  `thread_starter_messages` (provisioning notices, which are the OLDEST messages in a channel and
  so the first to fall out of reach). The double now answers newest-first like the port it stands
  for, which its own storage order did not. Test the walk directly against a paging fake: pages,
  cursors, the short-page stop, the stalled-cursor stop, and a failing page failing the read.
- **Second face, fixed in the same change — `REQ-SCAN-KEEP-OLDEST`:** duplicate cleanup kept the
  FIRST post found in the wanted thread, and the port answers newest-first, so completing the
  scan would have deleted every original and kept every flood copy — moving months-old cards to
  the bottom of a thread `CONTEXT.md` says reads oldest-first. The survivor is now the oldest by
  message id. Ending a flood must not rewrite the history it duplicated.
- **cite:** Request #73 (`W47-PLAN.md`), reported live from the `⚙spt-bs-releases` Done thread.
  Mechanism confirmed at source in this repo (`src/daemon/run.rs`, `src/daemon/reconcile.rs`);
  trigger condition measured from GitHub truth 2026-08-21. Same family as 2.1
  (`REQ-HAZARD-RECONCILE-IDEMPOTENT`), which this defect violated the whole time: the cycle over
  a settled Project was not performing zero mutations, it was performing hundreds — idempotence
  held in the logic and was broken by the read underneath it.

### 2.12 A bot-authored system message read as a bot post — `REQ-HAZARD-BOT-AUTHORED-SYSTEM-MESSAGE`

<!-- [doc->REQ-HAZARD-BOT-AUTHORED-SYSTEM-MESSAGE] -->

- **Failure:** `SerenityPort::bot_messages` and `pinned` narrowed a channel history by **author
  alone** (`m.author.id == self.self_id`). But Discord attributes its *own* system messages to
  whoever performed the act that triggered them, so a message can be authored by the bot and yet
  be something the bot never wrote and does not own. When `REQ-THREAD-NAME-PROJECT-INDEX` renamed
  every managed thread to carry its Project's index, Discord left one `CHANNEL_NAME_CHANGE`
  notice (`type: 4`, content = the new thread name) in **every** thread, authored by the bot, and
  those notices are permanent. Two consumers then reasoned about platform furniture as if it were
  a Bot post, and each reported it as a separate defect:
  - **Counting** — `desired_contents` publishes `bot_messages(thread).len()`, so every counted
    thread read exactly **one too many**, on every Project, for ever. Both Operator examples land
    on the nose: `spt-progress-tool` Eval showed `3` against 2 cards, `spt-bs-releases` Cut showed
    `1` against 0 cards. The offset is a constant `+1` because a thread is renamed to its index
    once (Request #75).
  - **Scanning** — the same message reaches `scan_channel`, parses no ref, and takes the orphan
    branch: one `has no parseable ref — left alone (orphan)` line per State thread and both
    Milestone threads, per Project, per cycle. Measured at ~50 lines a cycle and **2930** in a
    single daemon run. Nothing was mutated and the loud path was working exactly as specified —
    the damage is that a permanent, already-known condition was reported as news for ever, which
    is how a REAL orphan arrives invisible inside its own noise (Request #74).
- **Invariant:** A history read the daemon reasons from returns the bot's **posts** — messages the
  daemon itself wrote — never messages Discord authored *about* what the daemon did. Author is not
  the whole test; kind is part of it. The admitted kinds are an **allowlist** (`Regular`,
  `InlineReply`), because `MessageType` is `#[non_exhaustive]` with an `Unknown(u8)` arm: a notice
  the platform invents next year must default to "not a post" rather than silently readmitting
  this defect. Corollary, inherited from 2.11: the test double must be able to REPRESENT a
  bot-authored system message, or the class of message that causes this cannot exist anywhere in
  the suite.
- **Mapping / notes:** `run::is_bot_post` is the predicate and `run::bot_posts` the single narrowing
  both readers share, so `pinned` and `bot_messages` cannot drift apart about what a bot post is.
  It is deliberately NOT applied to `all_messages`, because `thread_starter_messages` needs the
  system messages intact — that reader wants exactly the furniture this one refuses. `FakeGuild`
  grows a `system` store and `rename_thread` records a notice into it, the way it already records
  a starter notice for `create_thread`. Test the predicate against serenity's own wire decoding
  (a raw `"type": 4`), not a hand-picked variant, plus the two board surfaces the Requests were
  reported from: a renamed thread's count is unchanged and its scan is silent, while a genuine
  ref-less post is still loud.
- **Deliberately NOT fixed by deleting them.** Request #74 offered "recognize them, or clear them".
  Recognizing them at the orphan branch would repair the scan and leave the count wrong, giving one
  cause two patches at two call sites. Clearing them is a mutation per cycle against messages that
  regenerate on the next rename — an index moves whenever the Project registry does — so filtering
  is idempotent where deleting races. The main channel's starter-notice clearing is not precedent:
  it serves a stated property (the channel stays Seeds-only, Operator request 2026-07-22), and no
  such property is stated for threads.
- **cite:** Requests #74 and #75 (`W48-PLAN.md`). Object named by a live read-only Discord read of
  `⚙alchemy-testbed` on 2026-08-21: message `1533809250323791952` in the Backlog thread, `type: 4`,
  content `"Backlog 1"`, dated 2026-08-03 — the day the Project-index rename shipped — with exactly
  one such message in each of that channel's 13 threads. Same family as 2.11
  (`REQ-HAZARD-BOARD-SCAN-WINDOW`): in both, the port answered with something that was not what the
  trait's contract said it was, and every consumer inherited the error. #74's thesis was proven in
  the same log: `⚙spt-bs-releases/Backlog` briefly carried a second orphan
  (`1534117665948434493`) that appeared in exactly two cycles and is now `10008 Unknown Message` —
  a genuine transient orphan that arrived and left inside the noise without anyone seeing it.
