# Provenance probe plan — #300

> JIT plan (AGENTS.md §Plans). **Nothing here is commissioned and nothing has been run.** doyle holds
> approval and sequences admission; no shared-host execution window is granted or held by anyone.
> No `REQ-*` is minted or activated by this document.
>
> Mirror-excluded by the root JIT-plan predicate (`:(glob)*-PLAN.md`) on its name alone.

Revision 2 makes **Probe B literal** per doyle's admission list: commands, fixed attempt count,
timeout, isolated paths, and a PID-plus-creation-time termination guard.

## Status of the three windows

| Probe | Status |
|---|---|
| **B** — resumed-session re-submit | **Chosen lane.** Specified below. Not commissioned. |
| **A** — Stop→next-turn | **Deferred by ruling.** See the caveat below; do not read it as ready. |
| External automation | **Deferred.** NOT equivalent to resume; a resume answer does not cover it. |

**A's deferral carries a correction I must not lose:** my A0 finding (no normal-path turn-end trace
line — only 19 lines of an exceptional "across-clear quiet window" suppressed branch, across both
trace generations) stands **only as a read of the log**. The BUSY→IDLE state-edge trigger I proposed
in its place **does not inherit that evidence**: the absence of a normal turn-end log line does not
establish that a state-edge watcher has equivalent timing. A's trigger is unvalidated, and A stays
deferred with it unvalidated.

---

# Probe B — resumed-session re-submit

**Question.** Can text reach the acceptance surface (`UserPromptSubmit`) again on resume, with no
human pressing send? If it can, a token captured at that second acceptance would attribute a replay
to whoever holds the seat at that moment — which is the failure #300 must not ship.

## Isolation — what is isolated, and what honestly is not

| Dimension | Isolation |
|---|---|
| Session | **Isolated.** Purpose-spawned, disposable, never a live agent's session. |
| Project dir | **Isolated.** Fresh scratch dir, import-free (no `CLAUDE.md`/`AGENTS.md`), outside the repo. |
| spt fleet state | **Untouched.** No endpoint created, no `state` written, no live endpoint driven. |
| Process identity | **Owned.** Spawned directly, so the PID is mine by construction (below). |
| **Config root** | **NOT isolated — stated rather than papered over.** |

The config root is shared with this account's other Claude Code sessions, and deliberately so: a
clean `CLAUDE_CONFIG_DIR` strands the Windows credentials and the spawn cannot authenticate
(`[[v0257-internal-session-isolation]]`). So the rig **reads** shared config and **writes** exactly
one thing — the folder-trust key for the new scratch project — via tmp + `os.replace`, never
open-truncate (`[[claudejson-write-hazard]]`). Choosing an **import-free** cwd is what avoids the
second startup gate (`hasClaudeMdExternalIncludesApproved`) and therefore avoids a second write
(`[[headless-spawn-external-imports-gate]]`). If doyle wants zero config-root writes, the alternative
is to reuse an already-trusted scratch dir, and I will name one rather than create one.

**Why a direct spawn rather than `spt endpoint run`.** doyle requires a PID-plus-creation-time
termination guard. Resolving the real `claude` WINPID behind an spt-hosted spawn is a known-open
problem on this node (`[[v0182-sptc-live-bringup-f034]]`), and `info.json` pids are stale everywhere
(`[[stuck-active-idle-blackhole]]`). A direct spawn returns the PID as a value, so the guard rests on
something I hold rather than something I infer. Cost: the trace line carries `id=-` instead of a named
endpoint, so correlation is by nonce and transcript — which doyle's first interpretation correction
requires anyway.

## Fixed parameters

| Parameter | Value |
|---|---|
| Resume attempts | **5** |
| No-resume control attempts | **5** |
| Total spawns | **10** (no high-N loop, no adaptive retry, no top-up if inconclusive) |
| Per-attempt timeout | **180 s** wall, hard bound; a breach is recorded as `TIMEOUT` and is a case, not a retry |
| Mid-turn kill delay | **2 s** after submission |
| Total wall estimate | **~35 min** including setup and harvest |

An inconclusive run does **not** authorise more attempts. It returns to doyle as an inconclusive
result with its counts.

## Paths (absolute, named)

```
RIG     C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\19d48c98-d787-4436-90c2-da3f52339ae1\scratchpad\probeB
WORK    <RIG>\work          # import-free cwd: contains no CLAUDE.md and no AGENTS.md
OUT     <RIG>\out           # per-case output, written immediately per case
TRACE   C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-claude-spt\hook-trace.log
CONFIG  C:\Users\decid\.claude-spt\accounts\alt          # SHARED, not isolated (see above)
TSCRIPT <CONFIG>\projects\<slug of WORK>\*.jsonl          # the disposable session transcripts
```

## Commands, as they will be run

**Setup, once.**

```powershell
$RIG  = 'C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\19d48c98-d787-4436-90c2-da3f52339ae1\scratchpad\probeB'
$WORK = "$RIG\work"; $OUT = "$RIG\out"
New-Item -ItemType Directory -Force -Path $WORK, $OUT
$TRACE = 'C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-claude-spt\hook-trace.log'
# Snapshot BOTH shared trace generations BEFORE anything runs
Copy-Item $TRACE        "$OUT\hook-trace.pre.log"
Copy-Item "$TRACE.1"    "$OUT\hook-trace.1.pre.log"
```

**Per attempt `$i` (1..5 resume; 1..5 control, identical except the resume step is skipped).**

```powershell
$NONCE = "PROBEB-$RUNID-$i"
$MARK  = (Get-Item $TRACE).Length          # trace offset, so this case reads only its own bytes

# 1. spawn, capturing PID *and* creation time together
$p    = Start-Process -FilePath 'claude' `
          -ArgumentList '-p', "Reply with exactly: $NONCE", '--output-format', 'json' `
          -WorkingDirectory $WORK -PassThru `
          -RedirectStandardOutput "$OUT\case-$i.spawn.json" -RedirectStandardError "$OUT\case-$i.spawn.err"
$pid0 = $p.Id
$st0  = $p.StartTime

# 2. let the turn get under way, then terminate mid-turn under the guard
Start-Sleep -Seconds 2
$live = Get-Process -Id $pid0 -ErrorAction SilentlyContinue
if ($live -and $live.StartTime -eq $st0) {
    Stop-Process -Id $pid0 -Force
    "KILLED pid=$pid0 start=$($st0.ToString('o'))" | Out-File "$OUT\case-$i.kill.txt"
} else {
    "NO-KILL pid=$pid0 expected_start=$($st0.ToString('o')) actual=$($live.StartTime)" |
        Out-File "$OUT\case-$i.kill.txt"     # exited on its own, or the pid was recycled -> case is void
}

# 3. RESUME ARM ONLY (skipped for the control arm)
$SID = (Get-ChildItem "$CONFIG\projects\$SLUG\*.jsonl" | Sort-Object LastWriteTime -Desc |
        Select-Object -First 1).BaseName
claude --resume $SID -p 'continue' --output-format json *> "$OUT\case-$i.resume.json"

# 4. preserve this case's evidence IMMEDIATELY, before the next case runs
$fs = [IO.File]::Open($TRACE,'Open','Read','ReadWrite'); $fs.Seek($MARK,'Begin') | Out-Null
(New-Object IO.StreamReader($fs)).ReadToEnd() | Out-File "$OUT\case-$i.trace.txt"; $fs.Close()
Copy-Item "$CONFIG\projects\$SLUG\$SID.jsonl" "$OUT\case-$i.transcript.jsonl"
```

Every attempt runs under the 180 s bound; on breach the attempt is terminated under the same guard
and recorded `TIMEOUT`.

**Two steps that must be validated in a dry run before the real one, and are called out rather than
assumed:** (i) that `UserPromptSubmit` fires at all in `-p`/print mode — if it does not, the rig must
move to an interactive spawn and the command list changes; (ii) that a session killed mid-turn leaves
a resumable session id on disk. Both are read-only checks against one throwaway spawn.

## Observable, and the reading — correlated, not counted

doyle: *"A nonce appearing twice in hook output establishes repeated observation, not necessarily a
new submission or new sender authorization."* The reading therefore requires **three correlated
facts**, never the hook count alone:

1. **Hook fires** — `BEGIN UserPromptSubmit` lines in this case's trace slice.
2. **Transcript submissions** — user entries in the resumed session's transcript carrying the nonce.
3. **Ordering** — whether a fire precedes or follows the resume, by timestamp.

| hook fires | transcript user entries | reading |
|---|---|---|
| 2 | **2** | a genuine **second submission** on resume — the surface is reachable with no human send |
| 2 | **1** | **repeated observation of ONE submission**, not a re-submit — no new acceptance, no new authorization |
| 1 | 1 | resume does not re-enter the acceptance surface |
| any | **0** | nonce never landed — **instrument failure, case void** |

Row 2 is the row doyle's correction exists to protect, and it is the one I would otherwise have
misread as a machine re-submit.

## Controls — what makes a null a measurement

- **Positive control that must count:** the FIRST, human-originated submission must be observed firing
  in the same case before any claim is made about a second. A rig that cannot see the fire it is
  built to count cannot report an absence (`[[v0410-hook-deadline-visible]]`).
- **Case validity gate:** a case counts only if its nonce appears in the transcript. That is what
  separates "the hook was silent" from "I failed to observe a hook".
- **Denominator guard:** the harvest refuses to print any rate when the valid-case denominator is
  zero, rather than printing a reassuring `0/0 = 0.00%`.
- **Negative control, with its limit stated:** the 5 no-resume cases show whether duplicate hook
  behaviour appears after termination alone. doyle's limit, carried verbatim into the reading: *"A
  no-resume control can expose duplicate hook behavior after termination, but cannot by itself
  attribute a difference specifically to resume."* A difference between arms is **suggestive, not
  attributive**, and the write-up must say so.

## Shared-host hazards and their mitigations

HFENDULEAM carries live agents and a CI runner.

1. **`hook-trace.log` is one rolling node-wide file** (512 KB, one previous generation, ~4.2 h
   retention). A probe that rolls it destroys another agent's in-flight evidence. Mitigation: both
   generations snapshotted before the run; 10 spawns total; each case's slice preserved immediately
   rather than harvested at the end.
2. **PID recycle is real on this box** — measured same-boot inside 30 s with both processes
   user-owned. Bare-pid targeting is therefore unsound, which is why the guard re-verifies **PID plus
   creation time immediately before the kill** and voids the case on mismatch rather than killing.
   Never match on image name: a name-matched cleanup once killed every `claude-spt.exe` on this
   runner (`[[ci-kill-scoping]]`).
3. **Trust store** — one atomic tmp + `os.replace` write, never open-truncate.
4. **No live sessions touched, no fleet endpoint state written.**

## Cleanup — perri owns it

Terminate any surviving spawned PID under the same guard; delete `<RIG>\work` and the disposable
session transcripts under the shared config root's project slug; leave `<OUT>` in place as the
evidence record. Remove the trust key added for the scratch project. Report the cleanup as performed,
per-item, rather than asserting it.

## Gate

For any code this produces: `sh ci/run-gates.sh` PASS **and** `traceable-reqs check` exit 0 before
anything lands. A throwaway rig stays in the scratchpad and is not committed; if it is kept it lives
under `ci/measure/` beside `trace-harvest.py`.

## Readiness

This plan is ready for doyle's review. It is **not** a request for a window: admission is sequenced
by doyle on this plan **and** hertz's combined validation package together, and my readiness does not
start a clock.
