---
name: v0410-hook-deadline-visible
description: "v0.41.0 SHIPPED 2026-09-10 + FIELD-VERIFIED 2026-09-11 (marker fires; core now 0.69.0). ⭐⭐ The first harvest printed `0/0 = 0.00%` — I guarded the PRESENCE OF LINES, not the DENOMINATOR. ⭐⭐ A refusal branch is meaningless without a positive control that must COUNT. UserPromptSubmit declared no timeout (CC's 5s default vs our p99 4835ms). ⭐⭐ The instrument built to catch timeouts COULD NOT SEE ONE — finish() is not a Drop guard and an external kill runs neither, so every distribution was survivorship-biased. ⭐⭐ `claude plugin install` on an already-installed plugin exits 0 WITHOUT moving the pointer. ⭐ I called a PACER defect that was my own backticks."
metadata:
  node_type: memory
  type: project
---

**v0.41.0 shipped** (commit `51f2942`, tag `v0.41.0`, mirror `4c504e3`, skeleton 0.1.20,
runbook fix `dfd1b87`). Gate PASS, `traceable-reqs` exit 0. `REQ-HOOK-DEADLINE-VISIBLE`.

**Operator trigger:** "`UserPromptSubmit hook timed out after 5s — output discarded`, seen
semi-often — is it claude-spt or spt-core?" Answer: **both, and the split is measurable.**

## What the stage trace actually said

99.4% of the hook's wall time is THREE SERIAL `spt` subprocess calls — `busy` 352ms mean,
`poll` 359ms, `now-signal` 569ms; every other stage sums to ~6ms. So the adapter owns the
COUNT (3 spawns/prompt), core owns the per-call LATENCY (~60ms healthy, one `poll` hit
10772ms). Totals p50 1020 / p90 2625 / p99 4835 / max 11715ms.

⭐ **Contention was REFUTED, not assumed**: slow hooks (>3000ms) had mean concurrency 0.02
vs 0.07 for fast ones. The busy ones were the FAST ones. I nearly shipped "node contention"
as the story.

## ⭐⭐ THE INSTRUMENT COULD NOT SEE ITS OWN SUBJECT

`StageTrace::finish()` is an explicit end-of-hook call, **not a Drop guard** — and an external
kill runs neither. So the trace built by the 2026-07-10 UPS-timeout RCA *specifically* so that
"a hook approaching the external deadline names the guilty stage instead of leaving a silent
timeout" **emitted nothing for every hook that actually timed out.**

Field-confirmed, not reasoned: doyle reported a 5s timeout while doyle's slowest surviving row
anywhere in the live log was 3885ms. The timing-out invocation wrote no line at all.

⭐ **Consequence is the real harm: every number I had was SURVIVORSHIP-BIASED.** Same family as
[[v0390-recharge-trust-trace]] (retention wiped the log) and [[node-local-book-gate]] — an
instrument that does not cover the population reads exactly like a clean result. I refused to
state a timeout rate, and the harvester now prints `NOT INSTRUMENTED` rather than `0` on an
archive predating the marker.

⭐ **A Drop guard would NOT have fixed it** — the same kill defeats it. The fix is a *pairable*
BEGIN line: each hook invocation is its own process, so an unpaired BEGIN pid IS a kill,
however the process died.

**Deliberately deferred:** cutting the 3 serial spawns. Deciding it on the survivor
distribution would be deciding it on the biased number this release exists to correct.

**Not a defect:** `REQ-HAZARD-PARK-DRAIN-DEADLINE` custody means a kill before emit
REDELIVERS — no mail was ever lost, only that turn's injection.

## ⭐⭐ `claude plugin install` DOES NOT MOVE THE POINTER

On an ALREADY-INSTALLED plugin it prints `✔ already installed` and **exits 0 without touching
`installed_plugins.json`**. 0.1.20 sat in `plugins/cache/cplugs/sptc/0.1.20/` with the fix while
the pointer still read 0.1.19 — staged, shipped, reaching nobody. `claude plugin update` is the
verb. Verify the POINTER, never the exit code:
`grep -o 'sptc[^,]*' ~/.claude/plugins/installed_plugins.json`

⭐ This is the #17 class one step LATER: there a skipped skeleton step stranded a fix; here the
step RAN and still delivered nothing — harder to catch because every command succeeded. Same
family as [[exit-zero-from-a-flag-order-noop]]. Runbook + `package-skeleton.sh` corrected.

## ⭐ I called a PACER defect that was my own formatting

I emitted `!!wait=8!!` **wrapped in backticks** three times, watched pacer nudge anyway, checked
its board (`checkpoints :0`, classify ignored) and concluded "that's a PACER defect." It is not.
The io-funnel showed my frames carried the marker inside backticks, while the `!!done!!` that
WORKED was bare. A tag inside backticks is a QUOTATION and fires nothing — the rule is in my own
brief, for peer tags and wake markers alike ([[v0387-legacy-wake-retired]] family). Retracted in
pacer's favour; bare marker then worked immediately (checkpoint `:1`, idle 1m→5m18s).
⭐ I formatted a wire token as code because it reads like one, and quoted it into silence.

## FIELD-VERIFIED 2026-09-11 — and the first harvest caught a defect in MY OWN reporter

Operator updated the node: core **0.69.0** (CLI+coordinator+broker agree), adapter **0.41.0**
installed, so the #37 floor that was refusing it is satisfied.

**Marker fires.** Paired BEGIN/TRACE from a real invocation, same pid, 495ms apart
(`pid 27828 id=perri`). Claimed narrowly: emitted before the work, names the same endpoint, pairs
by pid. Does NOT show a kill being caught — none has happened since.

## ⭐⭐ THE FIRST HARVEST PRINTED `deadline kills: 0/0 started = 0.00%`

The same false-clean this whole release is about, **one case narrower than the case I guarded.** I
refused to print a zero when the archive had NO BEGIN lines. I did NOT refuse when BEGINs existed
but none were old enough to judge — the single BEGIN sat inside the 60s grace window, denominator
zero, and `0/0` formatted as a measured rate of zero.

⭐⭐ **I put the guard on the PRESENCE OF LINES when it belonged on the DENOMINATOR.** A guard
written against "the instrument might be absent" does not cover "the instrument is present but has
nothing to divide by" — and both print the same reassuring zero. Fixed `d7d2763`: zero denominator
⇒ `INSUFFICIENT` naming why. ⭐ And case D asserts a REAL zero still prints as a rate, so the fix
cannot degenerate into suppressing every zero — the opposite failure.

## ⭐⭐ A REFUSAL BRANCH IS MEANINGLESS WITHOUT A POSITIVE CONTROL

`tests/deadline-kill-report.sh` asserts WHICH BRANCH each case takes. The load-bearing one is
**C: an old unpaired BEGIN must be COUNTED (1/2 = 50%)**. Without a case that must count, the two
refusal branches are indistinguishable from a reporter whose pairing is broken and can never count
anything — an instrument reading clean because it cannot see, i.e. the exact failure this whole
requirement exists to end. Mutation-checked: old expression reproduces `0/0 = 0.00%`, case B alone
catches it.

Same family as [[node-local-book-gate]] (validate the instrument before trusting a zero-hit) and
[[list-vs-predicate-assertions]]. **Still unmeasured and unestimated: the unbiased timeout rate** —
needs elapsed traffic, same accrual #32 waits on.

## ⭐ A SECOND deadline-dominating stage, isolated to core: `endpoint list --show-all` blocks ~10s

Only 3 of 1210 traces carry a `live-roster` stage above 0ms — and it is NOT the fallback identity
path (I said that first and it was wrong): the stage fires on `is_live_invocation(&prompt)`, i.e. a
**`/sptc:live` turn**. `identity[core-fallback]` / `id=-` merely CORRELATE, because a bringup
session is not registered yet. Rare because /sptc:live turns are rare, not because the slow path is.

Reproduced BY HAND on core 0.69.0, rc captured UNPIPED: **3822 / 10120 / 10105 ms, rc=0,
byte-identical 2497 bytes every run.** Not failing — waiting. The extra ~6.3s buys nothing.

⭐ **Isolated to core before filing**: claude-spt imposes NO timeout on that call, the bare CLI
reproduces it with no adapter involved, the call CANNOT move later
(`REQ-HAZARD-PARK-DRAIN-DEADLINE` puts the roster before the destructive park read), and capping it
myself would surface a partial roster on the one turn that exists to show bringup conditions. The
adapter's only lever is not calling it. Filed to doyle, who **RULED it core-side, not adapter
misuse** — `spt-bs-releases#299` (BUGFIX/BACKLOG). His contract read closes both workarounds
independently of my reasoning: `endpoint list` IS the full merged roster (`--show-all` adds
suspended), the bounded identity-only `whoami` is NOT a substitute for it, and **no numerical
full-roster latency guarantee is documented** — so the ~10s is undocumented cost, not a contract
violation. Nothing owed from claude-spt: no reordering, no timeout change, no probe.

Correlated, NOT causal (said so): `peer reachability: DEGRADED — 2 of 7 unreachable (quic-connect)`.

⭐ **Under the old 5s budget all three of those turns were KILLED, silently.** A second independent
reason the timeout raise was the right FIRST move: cutting the three serial `spt` spawns would have
saved none of them — the cost was ONE pure read blocking 10s.

Related: [[v0400-echo-race-workaround-retired]], [[v0390-recharge-trust-trace]],
[[list-vs-predicate-assertions]], [[v0294-0295-turnend-confirm-and-canonical-root]].
