# UPS-DEADLINE-PLAN — make the 5s UserPromptSubmit deadline survivable and visible

Operator report 2026-09-10: sessions running claude-spt see
`UserPromptSubmit hook timed out after 5s — output discarded` "semi-often";
most recent instance in doyle's session.

## What is measured (n=795 UPS traces / 30.14h / 9 endpoints, archive)

- total: p50 1020ms, p90 2625ms, p95 3402ms, p99 4835ms, max 11715ms, mean 1288ms.
- Stage means: `busy` 352ms, `poll` 359ms, `now-signal` 569ms; **every other stage sums to ~6ms.**
  99.4% of the hook's wall time is three serial `spt` subprocess calls.
- Worst single stage observed: one `poll` at 10772ms (flynn).
- Contention REFUTED: mean concurrent in-flight UPS hooks was 0.02 for slow (>3000ms) vs 0.07 for
  fast (<=1000ms). Slow hooks are not the busy ones.

## The two defects

1. **No `timeout` is declared.** `plugin/sptc/hooks/hooks.json` sets none on any event, so CC's
   5s UserPromptSubmit budget applies. Our own p99 is 4835ms — we sit ON the line by default.

2. **A killed hook leaves NO evidence.** `StageTrace::finish()` (hook.rs:1834) is an explicit
   end-of-hook call, not a Drop guard, and an external kill runs neither. So the instrument built
   by the 2026-07-10 UPS-timeout RCA to "name the guilty stage instead of leaving a silent
   timeout" cannot observe the very event it exists for. Confirmed against the field: doyle's
   session reported a 5s timeout while doyle's slowest row anywhere in the live log is 3885ms —
   the timing-out invocation wrote nothing.
   Consequence: the measured distribution above is SURVIVORSHIP-BIASED (completed hooks only) and
   the true timeout rate is unmeasured.

Not a defect: message bodies are NOT lost to a deadline kill —
`REQ-HAZARD-PARK-DRAIN-DEADLINE`'s transactional custody stages the drain and commits only after
emit, so a kill before emit redelivers. The user-visible loss is this turn's additionalContext
injection plus the error line.

## Tasks

- **T1 — declare the timeout.** Add an explicit `timeout` to the UserPromptSubmit entry in
  `plugin/sptc/hooks/hooks.json`. Value 20s: above the 11715ms worst observed total, because for a
  reachability adapter losing the drain is worse than making the user wait. Structural plugin
  change ⇒ needs the cplugs skeleton step, and does not reach a running `claude.exe` (the #24 wall).

- **T2 — make a killed hook visible.** Emit a short `BEGIN UserPromptSubmit id=<id>` line at hook
  entry. Each hook invocation is its own process, so an unpaired BEGIN pid (no TRACE with the same
  pid) IS a killed hook. Scope to UPS only — it is the only event that traces today, and pairing
  stays exact. Cost ~60B/invocation against a 512KB roll.

- **T3 — report the rate.** Teach `ci/measure/trace-harvest.py` to pair BEGIN/TRACE by pid and
  report unpaired BEGINs as deadline kills, so the timeout rate I currently cannot state becomes
  a measured number.

- **T4 — requirement + tests.** Mint a `REQ-*` covering both halves before satisfying it; unit
  test asserts a BEGIN precedes the TRACE and carries no prompt/body text (same privacy rule as
  `REQ-HOOK-STAGE-TIMING`).

## Gate

`sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0 before the commit lands.

## Open

- Whether to cut the three serial `spt` spawns to fewer calls. NOT in this plan: decide it on the
  T3 numbers, not on the survivor distribution.
