# 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.
- **cite** — where the failure / fix is evidenced (an incident, a prior commit, a source path).

---

## 1. Pacer-code parsing

<!-- [doc->REQ-HAZARD-PACER-CODE-FALSE-TRIGGER] -->
### 1.1 Code false-trigger from discussion

- **Failure:** an agent that *writes about* a **pacer code** — documentation, a message to a
  user, an agent explaining pacer — has `!!done!!` / `!!wait=m!!` in its turn text, and pacer
  reads it out of the digest and acts on it, silently pausing/delaying an agent that never
  intended to control pacer.
- **Invariant:** a code appearing **inside backticks or a fenced code block** is never acted on
  (the quotation-guard). Only a bare, unquoted, recognized code drives a control.
- **Mapping / notes:** digest reader/parser (`src/`, task 5 in `REBOUND-PLAN.md`). Test feeds
  digest `Agent` text with backticked and fenced codes and asserts no latch change; a bare code
  does change it. Residual (accepted): a *bare* code in prose still fires — docs/discussion
  almost always backtick literal tokens, so the real-world risk is small.
- **cite:** grill-with-docs session 2026-07-24 (Q7); mirrors claude-spt shortform's
  quotation rule.

<!-- [doc->REQ-HAZARD-PACER-CODE-REPLAY] -->
### 1.2 Code replay / double-action

- **Failure:** pacer polls the digest every ~5s; without dedup it re-acts on the *same* code
  every tick, and an *older* idle episode's code re-fires in a later one — a single `!!wait=5!!`
  becomes a permanent mute, or a stale `!!done!!` suppresses a fresh idle.
- **Invariant:** a code is **acted on at most once**, keyed on the digest `Agent` entry's
  committed `seq` (a monotonic watermark). Re-reads and prior-episode codes never re-fire.
- **Mapping / notes:** state machine + digest reader (`src/`). Test: same digest polled twice →
  one action; a lower-`seq` code after a higher-`seq` action → no action.
- **cite:** grill-with-docs session 2026-07-24 (Q7).

<!-- [doc->REQ-HAZARD-PACER-DELAY-PRE-IDLE] -->
### 1.3 A control consumed without taking effect (delay read before the idle edge)

- **Failure:** an agent writes `!!wait=m!!` in the turn it is finishing, so the code commits to
  the digest at **Stop** — the same instant the endpoint goes idle. The resident loop reads the
  digest *before* it drains the activity seam, so on the tick that straddles Stop the machine
  still believes the owner is **busy**. The `Delay` arm had nothing to defer and no-opped, while
  `ingest_codes` advanced the `seq` watermark regardless: the code was **consumed without acting**
  and — by the replay guard (1.2) — could never fire again. The base 60s ping then fired anyway.
  Observed as three consecutive 1-minute re-nudges against an agent emitting a wait code every
  turn, while the same codes were honored earlier the same day: which of the two signals wins the
  race is timing, so the defect is intermittent and reads like the code being ignored at random.
- **Invariant:** a **delay** that arrives while the owner still reads as busy is **held**, and the
  next busy→idle edge arms at `edge + m·min` instead of base — the wait bounds the stretch it was
  authored for. More generally: **no control is consumed without taking effect.** A held delay is
  consumed by exactly one edge (never leaking into a later stretch), a later `pause` outranks it,
  and `status` reports it as held so "landed" is distinguishable from "swallowed".
- **Mapping / notes:** `Machine::apply_control` / `on_activity` / `status_report`
  (`src/machine.rs`). Tests: a delay ingested while busy, then an idle edge → armed at the delay,
  no ping at 60s; the held delay does not bound the *next* stretch; a pause after it wins.
- **cite:** field defect 2026-07-26 — doyle's specimen, reproduced on `perri` and measured:
  the code committed with its `seq` ~1.4s after the entry timestamp and pacer's watermark
  advanced to exactly that `seq`, so the code was read in time and eaten. **Not** digest lag,
  which was the competing hypothesis.

<!-- [doc->REQ-HAZARD-PACER-MISSED-BUSY-EPISODE] -->
### 1.5 A busy episode nobody saw leaves a stale wait standing

- **Failure:** a standing `!!wait=30!!` outlived an operator interaction and a full reply turn —
  work that, per the contract, supersedes any standing wait. The activity frame is **drive-class
  and latest-wins**, and the published contract is explicit about what that costs a reader: "a
  missed frame is superseded by the next, so do not build on frame counts", and "a transition that
  happened between two polls is represented by the state the next frame carries, and `since` tells
  you when it actually happened". pacer built on frame counts anyway — it compared **state**
  only, so an idle frame arriving after an unobserved busy→idle round trip read as a redundant
  same-state resend and was dropped. Every way a busy frame can go missing lands here: coalesced
  between two polls, dropped while the shell was briefly offline, or lost to a failed poll (the
  resident is failure-blind by design, so a refused `drive-poll` silently yields nothing). The
  machine never learned its owner had worked, kept the armed 30m timer, and the wait outlived the
  activity that should have cleared it.
- **Invariant:** a same-state activity frame is a no-op **only when it carries the same instant**.
  A newer `since` on the same state is the collapsed report of a round trip through the other
  state, and is treated as a fresh edge: the idle stretch re-anchors to it, the backoff returns to
  base, and any standing or held wait is cleared — return-to-work supersedes a wait whether or not
  pacer witnessed the work. An older `since` never rewinds the anchor.
- **Mapping / notes:** `Machine::on_activity` (`src/machine.rs`) and
  `DrivePollActivitySource::drain` (`src/main.rs`) — the source must pass the frame on, or the
  machine never gets the chance to re-anchor. Tests: a wait armed, then an idle frame at a newer
  instant → re-armed at base with the wait cleared; the same instant (and an older one) still a
  no-op; a widened backoff returns to base. The pre-existing "redundant same-state is a no-op"
  test asserted the defect AS the contract (it resent idle at a *later* instant) and was corrected.
- **cite:** operator-raised, relayed by doyle 2026-07-26, on v0.2.1 bits. The cause CLASS is
  settled by the published contract rather than by measurement; which of the three ways the busy
  frame went missing produced doyle's specimen is **not** established — the fix removes the
  symptom for all three, and the specimen's own discriminator (his `status` line at a
  return-to-work) was never captured.

<!-- [doc->REQ-HAZARD-PACER-ORPHAN-RESIDENT] -->
### 1.4 An orphaned resident that outlives its instance

- **Failure:** `spt shell relink` re-spawns the binary with a **fresh link token**; the superseded
  process has no exit path — every `spt` failure is swallowed so a transient error cannot wedge
  the loop — so it spins on forever. Observed 2026-07-26: **two** `pacer-shell` processes alive
  against **one** online instance, the older one ~40 minutes past its supersession. Each orphan
  spawns `spt` subprocesses twice a tick and holds the binary's file lock (a `cargo install`
  fails `Access is denied` until it is killed). Its command drain and activity seam are both
  refused, so it cannot ping — but the **digest read needs no link token**, so its education path
  is still live and fires on every new context boundary (read from the code, not observed).
- **Invariant:** a resident whose link token is no longer held **exits**. The judgement is
  deliberately narrow: the refusal prefix (`AUTH_REFUSED` / `DRIVE_POLL_REFUSED`) **and** the
  reason (`no instance holds this link token`), on consecutive ticks. Any unrecognized failure is
  transient and keeps the resident alive — a false positive kills a working shell, which is worse
  than the orphan it would prevent.
- **Mapping / notes:** `link_refused` + the poll drain (`src/main.rs`). Test: both refusal lines
  classify; silence, a connection error, a different reason, a different prefix, and the bare
  phrase do not. **Interim by design** — doyle's `ResidentService` substrate (W1, in build) gives
  quiesce / teardown / orphan-sweep from core to a shell declaring a `[service]` section; this
  hand-rolled exit retires when pacer adopts it.
- **cite:** found 2026-07-26 while installing the delay fix — the install failed on the file lock
  the orphan held. Exit **driven, not asserted**: a resident run *by hand* against a live token
  (so the daemon owned no child to reap) exited on its own ~5s after the instance was torn down,
  logging `link no longer held by PACER-0 — superseded, exiting`. Driving it through a
  daemon-spawned resident proves nothing — that one dies in under a second because teardown reaps
  its own child, a different mechanism entirely.

## 2. Identity and durable state (M3)

<!-- [doc->REQ-HAZARD-PACER-CAPS-NAME] -->
### 2.1 The caps adapter id silently degrading

- **Failure:** the whole point of the caps id (`PACER` ⇒ `PACER-0`, ADR-0002) is that the sender
  an agent sees byte-matches the ref its owner types. If caps registration regresses in core, or
  any of our own steps re-cases the id (manifest, bind parse, send argv), the `from` label and
  the addressable ref drift apart — the agent sees a sender nobody can address.
- **Invariant:** the manifest declares `name = "PACER"` exactly; the id parsed at bind is carried
  into every `--from` verbatim. Caps acceptance and verbatim render were **probed live before
  build** (2026-07-26: `spt adapter add` registered/listed `PACER` verbatim; a receiving agent's
  EVENT showed `from="PACER-0"` untouched). The ruled fallback (lowercase adapter + upcased
  from-label) is adopted only via operator ruling, never silently.
- **Mapping / notes:** `manifest.toml` (`[adapter].name`), `resolve_ids` + `send_argv`
  (`src/main.rs`). Test pins the manifest literal and the case-preserving chain.
- **cite:** ADR-0002; probe records in this session's build log (task 1, 2026-07-26).

<!-- [doc->REQ-HAZARD-PACER-STATE-WIPE-ON-UPDATE] -->
### 2.2 `adapter update` wiping the durable state

- **Failure:** `spt adapter update` re-extracts the release archive into the install dir. If that
  re-extraction clears the directory rather than overlaying it, `state/<owner>.json` — the
  classification registry, pending stretch ids, and the code watermark — dies on every update:
  averages reset, reported ids orphan, and (worst) the watermark rewind re-fires already-acted
  steering codes.
- **Invariant:** `state/` under the install dir survives an `adapter update` re-extraction.
  **OBSERVED SURVIVING 2026-07-27**, on the first live update (0.3.0→0.4.0, this node): the
  canary file and `state/perri.json` came through byte-identical (sha256 pre/post), the update
  overlay-extracted `manifest.toml` + the binary (the running exe was renamed `pacer-shell.exe.old`,
  the Windows in-use workaround) and left `state/` untouched. Update semantics are overlay, not
  clear. The canary stays planted — every future update re-runs the observation for free.
- **Mapping / notes:** `src/state.rs` (the file layout); the canary is operational, not unit —
  a marker under `state/` (`update-wipe-canary.txt`), checked after each update.
- **cite:** ruled in the M3 grill (2026-07-26); doyle confirmed archive extraction
  selects+flattens per-triple subdirs; overlay-on-update observed 2026-07-27
  (milestone #10 acceptance record).

## 3. Inward-only purity

<!-- [doc->REQ-HAZARD-PACER-NO-SIGNOFF] -->
### 3.1 Pacer must never cause signoff

- **Failure:** a **ping** (or any pacer output/behavior) instructs or induces the agent to sign
  off / shut down its endpoint, so an autonomous nudge tears an agent down — a destructive action
  that only a user may command.
- **Invariant:** no pacer-originated content or code path ever instructs, encourages, or
  triggers `spt endpoint shutdown/stop` or a signoff. pacer's only outward act is the
  `--idle-only --ephemeral` ping, which contains no signoff directive.
- **Mapping / notes:** ping payload builder + any capability handling (`src/`). Test asserts the
  rendered ping body contains no signoff directive and pacer exposes no shutdown path.
- **cite:** grill-with-docs session 2026-07-24 (Q10); ties to the Q1 pure-inward-nudge decision.

## 4. Command-channel authenticity

<!-- [doc->REQ-HAZARD-PACER-FRAME-MAC] -->
### 4.1 Acting on an unauthenticated command frame

- **Failure:** pacer drains the durable command channel (`api poll --link`) and acts on the
  `pause` / `delay` verbs it finds. The published frame contract stamps every spooled frame
  (`<mac-hex> <EVENT …>`) and requires the binary to **verify the MAC before parsing** and drop
  a frame whose stamp does not match. pacer instead split the stamp off and discarded it, so
  any line reaching that drain in the documented shape was obeyed. A forged or corrupted
  `pause` frame **lowers the latch** — pacer goes quiet and the agent it was pacing stalls
  silently, which is precisely the failure pacer exists to prevent. A `delay` frame mutes it
  for up to two hours the same way.
- **Invariant:** a spooled frame is acted on **only** if its MAC verifies — key `SHA-256(link
  token)`, stamp `HMAC-SHA256(key, frame-bytes)` over the frame's on-wire (escaped) form, the
  bytes after the single separating space, compared as lowercase hex. An unstamped,
  mis-stamped, or truncated line is dropped without parsing. Verification happens **before**
  entity-decoding, per the contract's decode order.
- **Mapping / notes:** `frame::parse_capability` (`src/frame.rs`) — it takes the link token and
  verifies before it decodes anything. The activity frame is deliberately **exempt**: it rides
  the ephemeral drive drain, is documented as *not* MAC-stamped, and the link token presented on
  the poll authenticates the whole reply. Tests: a correctly stamped `pause` acts; the same
  frame under a different link token does not; a corrupted stamp, a missing stamp, and a
  truncated hex stamp are all dropped.
- **cite:** spt developer docs, `shells/frames` — "The envelope and the stamp" ("Verify before
  parsing… drop a frame whose MAC does not match"). Found 2026-07-25 reading the published
  contract after building the drain from `--help` output alone.
