# Duplicate render of a message burst: RCA (hertz, 2026-09-26)
- Assigned by doyle PYY3CZUG, on operator direction. Adapter contact: perri, whose trace evidence (3MIEIRLT) I verified independently below.
- Versions: spt-core 0.73.0, claude-spt 0.41.3 (tag v0.41.3, commit c9aa95c). Every line cite below is at those tags.
- Scope: RCA only, no fix.

## Verdict
- **Owner: claude-spt.** The dedupe lives in the adapter's park staging. The premise that breaks is that hooks never overlap.
- **Core is by-contract.** It offers one stub per spooled row, and the adapter is written to expect several stubs.

## Mechanism, with file:line
1. **Core, idle-inject, one offer per row (by contract).**
   - spt-core `crates/spt-daemon/src/inject.rs:184-185` (`drain_spool_offering`): the rows are claimed once, then each one is offered separately through `try_spt_hosted_inject`.
   - A burst of 2 rows therefore makes 2 translation calls and 2 stubs typed back to back.
   - The adapter expects this. claude-spt `hook.rs:700-707` (`coalesced_stub_note`) documents "several stubs typed before the first turn fired".
   - This is not the defect.
2. **Adapter, translate (one park per message).**
   - `translate.rs:439-456`: each message body is written as its own `<millis>-<seq>.park`, and the typed stub is `<msg from="X"/>` (`translate.rs:412`).
3. **Adapter, drain-then-commit staging.**
   - `hook.rs:5101-5145` (`drain_msg_park`): every `.park` is RENAMED to `.park.pending` (the stage). Any `.pending` it finds is read as "a prior hook died before its commit" and REDELIVERED (`hook.rs:5122-5126`).
   - `hook.rs:5147-5159` (`commit_msg_park`): every `.park.pending` in the directory is deleted, including files a different hook staged.
   - The caller is UserPromptSubmit: `hook.rs:3162-3172` drains and `hook.rs:3303` commits after the emit.
4. **The broken premise.** CC ran the 2 stub turns' UserPromptSubmit hooks CONCURRENTLY. Hook 2 saw hook 1's in-flight `.pending` files, misread them as a dead hook's leftovers, and rendered both bodies again.
   - The already-arrived check keys on park EMPTINESS (a file listing), never on msg-id or a seen set. It shows `coalesced_stub_note` only when the drain returns nothing (`hook.rs:3164-3168`).
   - flynn got the note because his hooks did not overlap: hook 2 ran after hook 1's commit and found an empty park.

## Evidence (MEASURED, from hook-trace.20260926.log, copied here)
- **The incident, epoch ms:**

  | Time | pid | Event |
  |---|---|---|
  | 37778 | 53388 | BEGIN UPS id=doyle |
  | 38111 | 60640 | BEGIN UPS id=doyle (333 ms later; 53388 still live) |
  | 38111-38112 | 60640 | REDELIVERY 1790387934365-000081.park.pending, then -000082 |
  | 38259 | 53388 | TRACE (park-drain=1ms), then commit |
  | 38609 | 60640 | TRACE, then commit |

  The park file names (…34365 and …34537) match core's single idle-inject take at 01:58:54Z.
- **Population over the whole log** (1,895 events, 2026-09-25T02:57Z to 2026-09-26):
  - 206 UPS BEGINs, and **0 hooks died**: every BEGIN has its TRACE. The redelivery path's intended cause did not occur once.
  - 11 REDELIVERY lines in 4 episodes. **Every episode ran while another UPS hook for the same endpoint was live**, and 5/5 same-endpoint overlapping pairs redelivered.
  - Earlier episodes, same shape: 2026-09-25T11:58:57Z (THREE overlapping hooks, each redelivering 3 parks) and 12:00:05Z.
  - So the protection has produced only false positives in this window, and this is a recurring race, not a one-off.
- **Limit of the instrument:** the trace records only UPS BEGIN/TRACE. PreToolUse is invisible to it.

## A second face (INFERRED, NOT measured)
- **PreToolUse drains and commits the same park.** `hook.rs:3924` drains and `hook.rs:4006` commits, inside `handle_pre_tool_use` (begins at :3832). So UPS+PTU and PTU+PTU (parallel tool calls) overlaps reach the same race.
- **The mirror face of the race is LOSS.** `commit_msg_park` deletes every `.pending`, so a hook that commits can delete bodies a concurrent hook staged and has not emitted yet. If that other hook is then killed at its deadline, the body is gone.
- Not observed. It needs a deadline kill, and this window had 0 kills. Any fix should close both faces.

## Pre-registered repro (not yet run)
- **R1, hook-level and deterministic** (no CC session; a temp HOME with a disposable endpoint's msgpark dir):
  - Arms:
    - **(a) OVERLAP:** 2 `.park` files; start hook A (UPS, stub prompt), then start hook B 100-300 ms later, while A is still live. A's measured lifetime is about 480 ms, and park-drain runs first.
    - **(b) SEQUENTIAL control:** 2 parks; start hook B after A has exited.
    - **(c) SINGLE negative control:** 1 park, 1 hook.
  - Statistic: the total count of each msg-id across the hooks' additionalContext, plus the count of REDELIVERY trace lines.
  - Prediction:

    | Arm | Renders per msg-id | REDELIVERY lines | Note |
    |---|---|---|---|
    | (a) | 2 each (4 total) | 2 | |
    | (b) | 1 each (2 total) | 0 | B emits the coalesced note |
    | (c) | 1 | 0 | |

  - Falsified if (a) shows 1 render each with the overlap confirmed by the trace (B's BEGIN before A's TRACE), or if (b) or (c) show any REDELIVERY.
- **R2, field shape:** a burst of 2 from one sender to an idle spt-hosted CC session (same sender, so one idle-inject claim types 2 stubs).
  - Prediction: a doubled render EXACTLY when the trace shows the 2 UPS hooks overlapping.
  - Negative control: 1 message gives 1 render and 0 REDELIVERY.
  - The overlap is timing-dependent, so R2 reports its overlap rate. R1 is the discriminator.

## Fix direction (for the owner once ruled; not applied)
- Perri's candidate is sound, and it must also cover the commit side:
  - **Stage ownership:** put the staging hook's pid (plus its start time, against pid reuse) in the `.pending` name. Redeliver only when that pid is dead.
  - **Commit ownership:** `commit_msg_park` deletes only the files THIS hook staged.
- A pure age threshold closes face 1 but not the loss face.
- Optional core hardening (not the root): offer a claimed burst as ONE translation call, so N rows make 1 stub. It shrinks the window but does not close it; any two hooks can still overlap.

## doyle ruling (2026-09-26)
- OWNER = claude-spt (perri). Core's per-row idle inject (inject.rs:184) is by design. The adapter already models several stubs.
- The fix must close BOTH faces hertz named:
  - STAGE: redeliver a .pending only when its staging hook is proven dead (pid + start time in the name).
  - COMMIT OWNERSHIP: a hook deletes ONLY the files it staged. This closes the loss face, and it covers the PreToolUse drain/commit too.
  An age threshold alone is refused.
- REGRESSION = hertz's pre-registered R1 (arms a/b/c) plus a PTU-overlap arm, owned and run by perri as the fix's own gate. hertz is released; doyle does not QC claude-spt.
- CORE DOCS GAP (doyle's, ride #355): the public contract never states that each queued message is injected as its own delivery. A burst of N yields N prompts, which a harness may process with overlapping hooks. Add the sentence to the messaging / harness-contract inject section.
