# 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**, and **already trusted** — a named existing key, not a new one (below). |
| 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.** Shared, read-only, **zero writes**. |

**Shared credentials do not imply isolated configuration.** That limitation is recorded here rather
than dissolved by the workaround: the rig authenticates using this account's credentials out of the
shared config root, and a clean `CLAUDE_CONFIG_DIR` would strand the Windows credentials so the spawn
could not authenticate at all (`[[v0257-internal-session-isolation]]`). The probe therefore runs
*inside* a configuration it shares with live sessions. Nothing below makes that untrue; it only stops
the rig from *writing* there.

**Zero config-root writes, by reusing an already-trusted key.** `WORK` is not a fresh directory. It is
this exact path, which already carries `hasTrustDialogAccepted` **and** both external-include
approvals in the shared `.claude.json`:

```
C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\61a5cd70-a460-42b8-9332-60f6bfe05a40\scratchpad\trust-probe31
```

Two facts about it, both **verified read-only** rather than assumed:
- **The directory is gone; its trust key is not.** A previous session cleaned the directory up and the
  key survived it. Recreating that exact path therefore inherits the existing trust with **no
  `.claude.json` write at all** — neither the trust key nor the external-include flags
  (`[[headless-spawn-external-imports-gate]]`, `[[f027-trust-seam-groundtruth]]`).
- **The parent chain is import-free.** Walked from that directory to the drive root: no `CLAUDE.md`
  and no `AGENTS.md` anywhere on it. So no external-import gate arises in the first place.

Because `WORK` is an existing key rather than one I make, the import-free property is **verified, not
guaranteed by construction** — and the chain could change. Re-run that walk immediately before the
battery and abort if anything appears on it. Recreating the directory is a filesystem action in the
run's setup, not a config mutation, and it is not covered by any grant until the run is.

**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** |
| **Process launches (the real bound)** | **15** — see the inventory below. Cases are not launches. |
| Per-attempt timeout | **180 s** wall, hard bound; a breach is recorded as `TIMEOUT` and is a case, not a retry |
| Kill delay | **2 s** after submission. **This does not establish that the turn was live at termination** — see window classification. |
| Total wall estimate | **~35 min** including setup and harvest |

### The mid-turn window is a separate, mostly UNESTABLISHED axis

A flat 2 s delay does not establish that the turn was live when the process was killed, and neither
does any transcript evidence (which gives a *recording* time that buffering may place after the live
turn ended). Every case therefore carries a window class — `WINDOW-UNESTABLISHED` by default,
`WINDOW-EVIDENCED` only on an artifact that actually attests liveness, `WINDOW-NOT-LIVE` on positive
evidence the turn had ended — reported alongside the four counts. It is **not** a relabelled void: a
case can be fully evaluable for D2 while its window is unknown. Any claim about *mid-turn* semantics
must carry the class; a case must never be written up as a mid-turn kill on the strength of the
delay having elapsed.

### Launch inventory — the bound is over PROCESSES, not cases

Five resume plus five controls is ten *cases*; it is not ten process launches. Each resume case
launches a second `claude` at the resume step, so:

| Arm | Cases | Initial launches | Resume launches | Launches |
|---|---|---|---|---|
| Resume | 5 | 5 | 5 | **10** |
| No-resume control | 5 | 5 | 0 | **5** |
| **Battery total** | **10** | **10** | **5** | **15** |

The precondition check is **not** in this inventory: it is a separate submission with its own single
launch (see `PROVENANCE-PRECHECK-PLAN.md`).

Every launch is inventoried per case at run time — initial and resumed alike, each with its PID and
creation time — so the record shows processes started, not cases intended. An inconclusive run does
**not** authorise more attempts or more launches. It returns to doyle as inconclusive 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    C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\61a5cd70-a460-42b8-9332-60f6bfe05a40\scratchpad\trust-probe31
                            # ALREADY-TRUSTED key, absent on disk, recreated empty. Zero config writes.
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 = 'C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\61a5cd70-a460-42b8-9332-60f6bfe05a40\scratchpad\trust-probe31'
$OUT  = "$RIG\out"
New-Item -ItemType Directory -Force -Path $WORK, $OUT | Out-Null
$TRACE = 'C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-claude-spt\hook-trace.log'

# Every variable the per-attempt block uses is assigned HERE, so the block runs as pasted.
$RUNID   = (Get-Date -Format 'yyyyMMdd-HHmmss')
$CONFIG  = $env:CLAUDE_CONFIG_DIR
if (-not $CONFIG) { throw 'CLAUDE_CONFIG_DIR unset - resolve the config root explicitly before running' }
$SLUG    = ($WORK -replace '[:\\]', '-')      # C:\a\b -> C--a-b, CC's project-slug rule
$TIMEOUT = 180                                 # seconds, per attempt, ENFORCED below

# 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"
# Trace offset AND the generation's identity. hook-trace.log is node-wide and rolls on the whole
# box's traffic, not just these 10 spawns, so an offset alone can point into a DIFFERENT
# generation's bytes and still parse as a slice. Capture identity with the mark.
$tf    = Get-Item $TRACE
$MARK  = $tf.Length
$MARKID = $tf.CreationTimeUtc

# 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

# One guarded terminator, used by every step that can leave a process behind.
function Stop-Guarded($id, $start, $label) {
    $live = Get-Process -Id $id -ErrorAction SilentlyContinue
    if ($live -and $live.StartTime -eq $start) {
        Stop-Process -Id $id -Force
        "KILLED $label pid=$id start=$($start.ToString('o'))"
    } else {
        # exited on its own, or the pid was RECYCLED onto another process -> never kill, void the case
        "NO-KILL $label pid=$id expected_start=$($start.ToString('o')) actual=$($live.StartTime)"
    }
}

# 2. let the turn get under way, then terminate mid-turn under the guard (bounded by construction)
Start-Sleep -Seconds 2
Stop-Guarded $pid0 $st0 'spawn' | Out-File "$OUT\case-$i.kill.txt"

# 3. RESUME ARM ONLY (skipped for the control arm). This is the step that could hang, so it is
#    spawned rather than run in the foreground and is held to $TIMEOUT, then killed under the guard.
$SID = (Get-ChildItem "$CONFIG\projects\$SLUG\*.jsonl" | Sort-Object LastWriteTime -Desc |
        Select-Object -First 1).BaseName
$r    = Start-Process -FilePath 'claude' `
          -ArgumentList '--resume', $SID, '-p', 'continue', '--output-format', 'json' `
          -WorkingDirectory $WORK -PassThru `
          -RedirectStandardOutput "$OUT\case-$i.resume.json" -RedirectStandardError "$OUT\case-$i.resume.err"
$rpid = $r.Id; $rst = $r.StartTime
Wait-Process -Id $rpid -Timeout $TIMEOUT -ErrorAction SilentlyContinue
if (-not $r.HasExited) {
    "TIMEOUT resume pid=$rpid bound=${TIMEOUT}s" | Out-File "$OUT\case-$i.timeout.txt"
    Stop-Guarded $rpid $rst 'resume' | Out-File "$OUT\case-$i.kill.txt" -Append
}

# 4. preserve this case's evidence IMMEDIATELY, before the next case runs.
#    ROLL GUARD: the shared trace may have rotated since $MARK was taken, in which case the offset
#    points into a different generation and would yield garbage that still parses as a slice.
$tf2 = Get-Item $TRACE
if ($tf2.Length -lt $MARK -or $tf2.CreationTimeUtc -ne $MARKID) {
    "ROLLED mark_len=$MARK now_len=$($tf2.Length) mark_id=$MARKID now_id=$($tf2.CreationTimeUtc) - slice VOID" |
        Out-File "$OUT\case-$i.trace.txt"
} else {
    $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`.

### The preconditions are a SEPARATE submission, not part of this battery

The command list above is exact **conditional on two unknowns** (does `UserPromptSubmit` fire in
`-p`/print mode; does a mid-turn kill leave a resumable session id on disk). Clearing them means
running Claude Code on this host, which is execution needing its own admission.

They are therefore **not** in this plan and **not** in this battery's launch inventory. They are their
own bounded submission: **`PROVENANCE-PRECHECK-PLAN.md`**, one launch, its own commands and cleanup,
no trust writes, and **no automatic fallback** — a negative answer returns as a result and a decision,
never as a rig that reshapes itself.

**This battery is not runnable until that check has been admitted, run, and answered.**

## 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** |
| *slice `ROLLED`* | — | **evidence unavailable** — NOT a silent-hook finding, and must never be written up as one |

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". A case is **void**, not null, if
  its kill record says `NO-KILL` (pid recycled or already exited) or its trace slice says `ROLLED` —
  in both the observation is untrustworthy rather than negative, and a void case is excluded from the
  denominator and reported as void.
- **Denominator guard:** the harvest refuses to print any rate when the evaluable denominator is
  zero, rather than printing a reassuring `0/0 = 0.00%`.
- **Four counts, reported separately — a void case must never disappear.** Excluding unavailable
  evidence from a rate's denominator must not also remove it from the report, so the write-up carries
  all four numbers side by side and never one denominator with an asterisk:

  | Count | Meaning |
  |---|---|
  | **Scheduled** | fixed up front: 10 (5 resume + 5 control) |
  | **Attempted** | how many actually launched |
  | **Evaluable** | what any rate is computed over |
  | **Unavailable** | `NO-KILL` (pid recycled or self-exited) and `ROLLED` (rotation unresolvable) |

  *10 scheduled / 10 attempted / 2 evaluable / 8 unavailable* is a legitimate and highly informative
  result. It must be impossible to read it as "2 of 2".
- **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 / shared config — NOT WRITTEN AT ALL.** The rig reuses an already-trusted key
   rather than creating one, so there is no `.claude.json` write to make safe. The named directory is
   verified read-only and is never altered to manufacture isolation: if it turns out to carry project
   config or an import, that is reported as a finding, not edited away.
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. **No trust key to remove — none was added.** 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.
