# Probe B precondition check — #300

> JIT plan (AGENTS.md §Plans). **Not commissioned. Nothing here has been run.** No execution and no
> config mutation is authorized; neither lane holds a window. doyle sequences admission.
>
> Mirror-excluded by the root JIT-plan predicate (`:(glob)*-PLAN.md`) on its name alone.

This is the **complete bounded precondition check** that must be admitted, run and answered before
`PROVENANCE-PROBE-PLAN.md`'s ten-case battery is runnable at all. It is its own unit with its own
commands, its own budget and its own cleanup, because clearing these unknowns is itself execution on
a shared host and cannot ride inside a plan that is granted for something else.

## What it answers, and what it must NOT do

| # | Question | What a NO changes |
|---|---|---|
| **D1** | Does `UserPromptSubmit` fire at all in `-p` / print mode? | The battery's whole command list rests on it. |
| **D2** | Does a session terminated mid-turn leave a resumable session id on disk? | The battery's resume step resolves `$SID` from disk; without one there is nothing to resume. |

**No automatic fallback.** If D1 is NO, this rig does **not** become an interactive rig, and the
battery is not rewritten on my own authority. A negative answer is a **result** that returns to doyle
with a recommendation, and the decision to re-shape the approach is his. The same for D2. This is
stated as a prohibition rather than a preference because the fall-through I originally wrote is
exactly the thing that would have made the battery reshape itself without a ruling.

**No trust writes** — and that is a narrower claim than "no writes". The trust store is not written;
transcript files *are* written into the shared config root, unavoidably, and one further write is
unknown without launching. All three are separated in Isolation and the third is put to doyle as his
decision rather than presented as solved.

## Budget

| Parameter | Value |
|---|---|
| **Process launches** | **1** (one `claude`; no resume launch — D2 is read off disk, not by resuming) |
| Timeout | **180 s** wall, externally enforced, terminated under the identity guard on breach |
| Mid-turn kill delay | **2 s** after submission |
| Wall estimate | **≤ 5 minutes** including setup, harvest and cleanup |
| Retries | **none.** A failed or void check returns as void; it does not re-run on my authority. |

This launch is **not** part of the battery's 15-launch inventory. The two are separate submissions
and separate budgets.

## Isolation — and the limitation, recorded rather than dissolved

**Shared credentials do not imply isolated configuration.** The check authenticates with this
account's credentials from the shared config root; a clean `CLAUDE_CONFIG_DIR` would strand the
Windows credentials and the spawn could not authenticate (`[[v0257-internal-session-isolation]]`). So
this runs *inside* a configuration shared with live sessions. The user-level layer of that config —
user settings and the user-level `CLAUDE.md` — **applies to this spawn**, and is in fact what supplies
the hooks the check observes. "Import-free" below means free of *project-scoped* imports; it does not
mean the session loads nothing.

**No trust-store write — but NOT "zero writes", and the difference is doyle's to rule on.**
Three separate claims, kept apart because only the first is established:

| Write | Status |
|---|---|
| Trust / external-include flags in `.claude.json` | **None.** The key already exists; nothing is written. Established. |
| Per-project session record in `.claude.json` (`lastStartTime`, `lastSessionId`, …) | **UNKNOWN.** The two real project entries in this root carry 26 keys each, so the runtime *does* write session records for normal sessions. `trust-probe31`'s clean 3-key shape is equally consistent with "a spawn that wrote nothing" and with "flags written by a trust acceptance where no session ever ran". I cannot tell which without launching, which is the thing not authorized. `lastStartTime` may well be written at startup, before the mid-turn kill. |
| Transcript `.jsonl` under `$CONFIG\projects\$SLUG` | **CERTAIN, and unavoidable.** Running `claude` at all under a shared root writes them there. Cleanup step 3 deletes them, which is itself the admission that they are written. |

The third is inherent to running Claude Code under a shared config root and cannot be engineered
away while credentials require that root. It is **disclosed for doyle's decision**, not presented as
solved: if writing transcripts into the shared account config directory is unacceptable, this check
cannot run in this shape at all.

`WORK` is an already-trusted key, not a new one:

```
C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\61a5cd70-a460-42b8-9332-60f6bfe05a40\scratchpad\trust-probe31
```

Verified **read-only**, today, and to be re-verified immediately before the run:
- It already carries `hasTrustDialogAccepted` **and** both external-include approvals in the shared
  `.claude.json`, so neither startup gate arises and neither flag needs writing
  (`[[headless-spawn-external-imports-gate]]`, `[[f027-trust-seam-groundtruth]]`).
- **The directory itself is absent** — a previous session removed it and the key outlived it. The run
  recreates that exact path as an empty directory. Nothing existing is modified: no file in it is
  edited, moved or deleted, because there is no directory there to alter.
- **Its parent chain carries no `CLAUDE.md`, no `AGENTS.md`, and no project-scoped `.claude/`**,
  walked to the drive root.

**The directory is never altered to manufacture isolation.** If the pre-run re-verification finds an
import or project config on that chain, the check **stops and reports it** as a finding. I do not
delete, move or rewrite anything to make the directory clean — that would be the write doyle ruled
out, arriving through a different door. If no suitable trusted directory exists at that point, that
is a finding for doyle, not a problem for me to fix by editing one.

Everything else matches the battery: no endpoint created, no `state` written, no live session touched,
PID owned by construction via a direct spawn.

## Commands, as they will be run

```powershell
# --- setup -------------------------------------------------------------------
$RIG     = 'C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\19d48c98-d787-4436-90c2-da3f52339ae1\scratchpad\precheck'
$OUT     = "$RIG\out"
$WORK    = 'C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\61a5cd70-a460-42b8-9332-60f6bfe05a40\scratchpad\trust-probe31'
$TRACE   = 'C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-claude-spt\hook-trace.log'
$CONFIG  = $env:CLAUDE_CONFIG_DIR
if (-not $CONFIG) { throw 'CLAUDE_CONFIG_DIR unset - resolve the config root explicitly before running' }
$SLUG    = ($WORK -replace '[:\\]', '-')
$TIMEOUT = 180
$NONCE   = "PRECHECK-" + (Get-Date -Format 'yyyyMMdd-HHmmss')
New-Item -ItemType Directory -Force -Path $OUT | Out-Null

# RE-VERIFY the trusted dir read-only. Report and STOP on any finding; never clean it up.
$bad = @()
$probe = $WORK
while ($probe) {
    foreach ($f in 'CLAUDE.md','AGENTS.md','.claude') {
        if (Test-Path (Join-Path $probe $f)) { $bad += (Join-Path $probe $f) }
    }
    $parent = Split-Path $probe -Parent
    if (-not $parent) { break }      # Split-Path 'C:' -Parent is EMPTY, so the root is checked
    $probe = $parent                  #   by this iteration before the loop ends, not skipped
}
$bad = $bad | Where-Object { $_ -notlike "$env:USERPROFILE\.claude*" }   # user-level layer is expected
if ($bad) { $bad | Out-File "$OUT\precheck.ABORT.txt"; throw "project-scoped config on the chain - reporting, not cleaning" }

New-Item -ItemType Directory -Force -Path $WORK | Out-Null    # recreates the already-trusted path
Copy-Item $TRACE "$OUT\hook-trace.pre.log"; Copy-Item "$TRACE.1" "$OUT\hook-trace.1.pre.log"
$tf = Get-Item $TRACE; $MARK = $tf.Length; $MARKID = $tf.CreationTimeUtc

# --- the one launch ----------------------------------------------------------
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 { "NO-KILL $label pid=$id expected_start=$($start.ToString('o')) actual=$($live.StartTime)" }
}

$p    = Start-Process -FilePath 'claude' `
          -ArgumentList '-p', "Reply with exactly: $NONCE", '--output-format', 'json' `
          -WorkingDirectory $WORK -PassThru `
          -RedirectStandardOutput "$OUT\precheck.spawn.json" -RedirectStandardError "$OUT\precheck.spawn.err"
$pid0 = $p.Id; $st0 = $p.StartTime
"LAUNCH pid=$pid0 start=$($st0.ToString('o'))" | Out-File "$OUT\precheck.launches.txt"

Start-Sleep -Seconds 2
Stop-Guarded $pid0 $st0 'spawn' | Out-File "$OUT\precheck.kill.txt"
Wait-Process -Id $pid0 -Timeout $TIMEOUT -ErrorAction SilentlyContinue
$p.Refresh()          # HasExited caches; without this a stale read writes a spurious TIMEOUT
if (-not $p.HasExited) {
    "TIMEOUT pid=$pid0 bound=${TIMEOUT}s" | Out-File "$OUT\precheck.timeout.txt"
    Stop-Guarded $pid0 $st0 'timeout' | Out-File "$OUT\precheck.kill.txt" -Append
}

# --- harvest, rotation-aware -------------------------------------------------
$tf2 = Get-Item $TRACE
if ($tf2.Length -lt $MARK -or $tf2.CreationTimeUtc -ne $MARKID) {
    "ROLLED mark_len=$MARK now_len=$($tf2.Length) - EVIDENCE UNAVAILABLE (not a silent-hook finding)" |
        Out-File "$OUT\precheck.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\precheck.trace.txt"; $fs.Close()
}
Get-ChildItem "$CONFIG\projects\$SLUG\*.jsonl" -ErrorAction SilentlyContinue |
    Select-Object Name, Length, LastWriteTime | Out-File "$OUT\precheck.sessions.txt"
Get-ChildItem "$CONFIG\projects\$SLUG\*.jsonl" -ErrorAction SilentlyContinue |
    ForEach-Object { Copy-Item $_.FullName "$OUT\precheck.$($_.Name)" }
```

## Reading

### Instrument presence is checked BEFORE D1 is read

An empty slice means "no `UserPromptSubmit` fired" **only if the adapter's hooks were attached to
this spawn at all**. A spawn that loaded no hooks produces a byte-identical empty slice, and reading
that as "print mode does not reach the surface" would be a finding manufactured out of a missing
instrument. So D1 is split and ordered:

- **D1a — was the instrument present?** Does this spawn produce *any* adapter hook evidence: a hook
  attachment of any `hookName` in its own transcript, or any trace line attributable to its window.
  **No evidence of any hook ⇒ the check is VOID (instrument absent), never D1 = NO.**
- **D1b — did `UserPromptSubmit` fire?** Evaluated **only** if D1a is YES.

**A control proposed for this does NOT work, and I checked rather than adopting it.** Requiring a
`SessionStart` line in the slice would void every run: across both trace generations all 23 lines
mentioning `SessionStart` are *conditional* messages (across-clear latch release ×18, `STRANDED` ×3,
`LATE_ACTIVATION` ×2). **There is no routine per-session-start trace record**, which independently
confirms an older finding of mine that this trace has never carried SessionStart output. A control
that cannot fire is worse than none: it converts every result into a void.

**Residual, stated rather than hidden:** a spawn whose hooks all fired *silently* (the trace is
conditional, and transcript attachments need the hook to emit something) is indistinguishable from a
spawn with no hooks. That biases D1a toward VOID — the safe direction. It can never manufacture a
D1 = NO, which is the failure that matters.

What is known in D1's favour, measured: the last two trace generations carry **110** `BEGIN
UserPromptSubmit id=-` lines, so the hook does fire for sessions with no endpoint id. That says
nothing about whether any of them was print mode.

| Observation | D1 | D2 |
|---|---|---|
| `BEGIN UserPromptSubmit` present in the slice | **YES** — print mode reaches the surface | — |
| slice present, no `BEGIN UserPromptSubmit`, **and** the nonce is in a transcript | **NO** — returns to doyle as a result | — |
| slice says `ROLLED` | **evidence unavailable** — NOT a "no", and must never be written up as one | same |
| nonce absent from every transcript | **instrument failure, check void** — the spawn never got as far as a submission | same |
| a `.jsonl` exists for the slug with the nonce in it | — | **YES** — a resumable id survives the kill |
| slug directory **exists**, no `.jsonl` in it (or none with the nonce) | — | **NO** — a real negative, returns to doyle as a result |
| slug directory **absent entirely** | — | **VOID** — the slug resolved wrong or the session never registered; instrument, not answer |

D1 and D2 are read from **different** artifacts (trace slice vs transcript directory), so one can be
answered while the other is void. Report them separately; never let a D1 answer imply a D2 one.

**Counts, reported separately even for a single case:** scheduled 1 / attempted / evaluable /
unavailable. A void check is reported as void, not as a negative.

## Cleanup — perri owns it, reported per item

1. Terminate any surviving PID from `precheck.launches.txt` under the identity guard; never by image name.
2. Remove the recreated `WORK` directory (it was created empty by this check and is left empty).
3. Remove the disposable session transcripts under `$CONFIG\projects\$SLUG\` **after** copying them into `$OUT`.
4. Leave `$OUT` in place as the evidence record.
5. **No trust key to remove — none was written.**

Report each item as performed rather than asserting cleanup as a whole.

## Gate

`sh ci/run-gates.sh` PASS and `traceable-reqs check` exit 0 for any code this produces. The rig stays
in the scratchpad and is not committed unless it is kept, in which case it lives under `ci/measure/`.
