# 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 transcript **persist** on disk through a mid-turn termination? | The battery's resume step resolves a session from disk. **Scope: persistence only.** A surviving file proves the record persisted, NOT that a resume succeeds — actual resumability remains a later operation this check does not perform. |

## ANSWER SPACE — read this before admitting it

**This check can return D1 = YES or D1 = evidence-unavailable. Via this trace it cannot return
D1 = NO.** That is a real limitation of **this instrument**, and doyle should admit it knowing so.

**Scope of the claim, stated precisely.** Absent UPS output cannot establish D1 = NO *without an
independent witness that the relevant hook configuration loaded for that spawn*. That specific
witness is what is missing — not hook evidence in general. This is a limitation of
`hook-trace.log`, **not a permanent impossibility across all instruments**: a different instrument
that attests the loaded hook configuration would answer it. My earlier generalisation ("only UPS can
ever be the witness") is correct about this trace and overreaches if read as a claim about every
possible instrument. It is not one.

**And a NO is not worth chasing.** A bounded check returning YES or evidence-unavailable is useful on
its own terms; instrumentation is deliberately **not** expanded merely to force a NO outcome.
{YES, evidence-unavailable} is an admissible answer space, not a deficiency to engineer around.

Why: for a spawn whose turn calls no tools, the only hook that can produce evidence is
`UserPromptSubmit` itself — so "was the instrument present" and "did UPS fire" are the same
observation, and the check is circular. Provoking a tool call was the proposed fix, to make a
*different* hook the witness. **Measured, and it does not work via the trace:** all 16 `PreToolUse`
lines across both trace generations are the exceptional `inbound SUPPRESSED / across-clear quiet
window` branch — there is **no routine PreToolUse trace record**, exactly as there is no routine
`SessionStart` one. Against 580 `BEGIN UserPromptSubmit` lines in the same span, the picture is
plain: **this trace routinely records `UserPromptSubmit` and nothing else.** Every other adapter hook
writes only on an exceptional branch.

So the trace cannot witness instrument-presence independently of the thing being measured, and a
missing UPS line stays indistinguishable from a missing instrument.

**What is still attempted, as best-effort and marked unmeasured.** The check's prompt provokes one
trivial tool call anyway, because the *transcript* is a second and independent instrument: a
`PreToolUse` hook that emits an envelope leaves a `hookName` attachment there (observed in perched
sessions). Whether a **non-perched print-mode spawn** produces one is **UNMEASURED** — it requires
launching. If it does, D1 = NO becomes observable and the answer space widens. If it does not, the
answer space stays {YES, VOID}. This can only widen the answer space, never narrow it, so it is worth
the zero extra launches it costs.

Permissioning is **not** expected to suppress it — the hook reference documents `PreToolUse` as
firing *before* the permission decision (it is what returns `permissionDecision`). That is **READ,
not measured**, and is not load-bearing: if permissioning does suppress it, the fallback is the
{YES, VOID} space already stated above.

**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) |
| Deadline | **180 s from launch — ONE clock.** The trigger wait is spent inside it, never added to it. Cleanup has its own separate 30 s bound. |
| Kill trigger | first tool-call evidence in the transcript, **bounded at 60 s**. Records the evidence actually observed; **neither path establishes a mid-turn kill** — see the window classification. |
| 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 '[:\\]', '-')
$PROJDIR    = Join-Path (Join-Path $CONFIG 'projects') $SLUG
$DEADLINE_S = 180      # ONE deadline, measured from launch
$CLEANUP_S  =  30      # separate bounded interval for cleanup
$TRIGGER_S  =  60      # trigger wait, spent INSIDE the deadline, never added to it
$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 of the drive root is EMPTY: the root is checked by
    $probe = $parent                 #   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' }

# --- argv proof against a harmless stub (NOT a claude launch) -----------------
# Start-Process re-joins ArgumentList, so a multiword prompt can arrive split. Prove the argv the
# child actually receives BEFORE spending the one launch.
$PROMPT = "Run one shell command that prints exactly $NONCE, then reply with exactly $NONCE"
$ARGS_V = @('-p', $PROMPT, '--output-format', 'json')
$stub   = "$OUT\argv-stub.ps1"
Set-Content -Path $stub -Encoding UTF8 -Value '$i=0; foreach ($a in $args) { "[{0}] {1}" -f $i, $a; $i++ }'
Start-Process -FilePath 'powershell' -ArgumentList (@('-NoProfile','-File',$stub) + $ARGS_V) `
    -NoNewWindow -Wait -RedirectStandardOutput "$OUT\precheck.argv.txt"
$argv = Get-Content "$OUT\precheck.argv.txt"
$hit  = @($argv | Where-Object { $_ -match '^\[1\] ' -and $_ -match [regex]::Escape($PROMPT) })
if ($hit.Count -ne 1) {
    "ARGV PROOF FAILED - prompt did not arrive as one argument:`n$($argv -join "`n")" |
        Out-File "$OUT\precheck.ABORT.txt"
    throw 'argv quoting unproven - not launching'
}

# --- pre-launch inventory: whatever already exists is NOT ours ----------------
New-Item -ItemType Directory -Force -Path $WORK | Out-Null    # recreates the already-trusted path
$PRE = @{}
if (Test-Path $PROJDIR) {
    Get-ChildItem "$PROJDIR\*.jsonl" -ErrorAction SilentlyContinue |
        ForEach-Object { $PRE[$_.FullName] = $true }
}
$PRE.Keys | Sort-Object | Out-File "$OUT\precheck.preexisting.txt"
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

# --- terminator: four distinguishable outcomes, and termination CONFIRMED -----
function Stop-Guarded($id, $start, $label) {
    $live = $null; $queryFailed = $false
    try { $live = Get-Process -Id $id -ErrorAction Stop }
    catch [Microsoft.PowerShell.Commands.ProcessCommandException] { $live = $null }   # genuinely absent
    catch { $queryFailed = $true }                                                    # the query itself failed
    if ($queryFailed)               { return "QUERY-FAILURE $label pid=$id residual_identity_start=$($start.ToString('o')) - NOT killed, identity preserved for follow-up" }
    if (-not $live)                 { return "ABSENT $label pid=$id - already exited, nothing killed" }
    if ($live.StartTime -ne $start) { return "IDENTITY-MISMATCH $label pid=$id expected=$($start.ToString('o')) actual=$($live.StartTime.ToString('o')) - PID RECYCLED, NOT killed" }
    Stop-Process -Id $id -Force -ErrorAction SilentlyContinue
    Start-Sleep -Milliseconds 300
    $after = $null
    try { $after = Get-Process -Id $id -ErrorAction Stop } catch { $after = $null }
    if ($after -and $after.StartTime -eq $start) { return "KILL-UNCONFIRMED $label pid=$id - still present after Stop-Process" }
    return "CONFIRMED-EXIT $label pid=$id start=$($start.ToString('o'))"
}

# --- the one launch ----------------------------------------------------------
$p    = Start-Process -FilePath 'claude' -ArgumentList $ARGS_V `
          -WorkingDirectory $WORK -PassThru `
          -RedirectStandardOutput "$OUT\precheck.spawn.json" -RedirectStandardError "$OUT\precheck.spawn.err"
$pid0 = $p.Id; $st0 = $p.StartTime
$LAUNCH_AT = Get-Date
$DEADLINE  = $LAUNCH_AT.AddSeconds($DEADLINE_S)     # the ONE clock, from launch
"LAUNCH pid=$pid0 start=$($st0.ToString('o')) deadline=$($DEADLINE.ToString('o'))" |
    Out-File "$OUT\precheck.launches.txt"

# Kill trigger: wait for first tool-call evidence, bounded by BOTH the trigger window and the one
# deadline, so the wait is spent INSIDE the deadline rather than added to it.
# NEITHER PATH ESTABLISHES THAT THE TURN WAS LIVE AT TERMINATION: transcript evidence gives a
# RECORDING time whose observation buffering may place after the live turn ended, and the bound
# firing says nothing about turn state at all. Record what was observed; classify the window.
$why = 'trigger-bound-reached'
$triggerEnd = $LAUNCH_AT.AddSeconds($TRIGGER_S)
while ((Get-Date) -lt $triggerEnd -and (Get-Date) -lt $DEADLINE) {
    $cand = Get-ChildItem "$PROJDIR\*.jsonl" -ErrorAction SilentlyContinue |
            Where-Object { -not $PRE.ContainsKey($_.FullName) }
    if ($cand | Where-Object { Select-String -Path $_.FullName -Pattern '"type"\s*:\s*"tool_use"' -Quiet }) {
        $why = 'tool-evidence-recorded'; break
    }
    Start-Sleep -Milliseconds 400
}
"KILL-TRIGGER path=$why at=$((Get-Date).ToString('o')) observed=$(if ($why -eq 'tool-evidence-recorded') {'tool_use RECORDED in a new transcript - a recording time, NOT proof the turn was live'} else {'nothing'}) window=UNESTABLISHED" |
    Out-File "$OUT\precheck.killtrigger.txt"
Stop-Guarded $pid0 $st0 'trigger' | Out-File "$OUT\precheck.kill.txt"

# Hold to the SAME deadline, never a fresh one
$remain = [int][Math]::Max(0, ($DEADLINE - (Get-Date)).TotalSeconds)
if ($remain -gt 0) { Wait-Process -Id $pid0 -Timeout $remain -ErrorAction SilentlyContinue }
$p.Refresh()          # HasExited caches; a stale read would write a spurious breach record
if (-not $p.HasExited) {
    "DEADLINE-BREACH pid=$pid0 one_clock_from_launch=${DEADLINE_S}s" | Out-File "$OUT\precheck.timeout.txt"
    Stop-Guarded $pid0 $st0 'deadline' | Out-File "$OUT\precheck.kill.txt" -Append
}

# --- harvest: rotation-aware, and ATTRIBUTED to this attempt only -------------
$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()
}

# THIS attempt's transcript = new since the inventory AND carrying the nonce. Never "the newest".
$MINE = @()
if (Test-Path $PROJDIR) {
    $MINE = Get-ChildItem "$PROJDIR\*.jsonl" -ErrorAction SilentlyContinue |
            Where-Object { -not $PRE.ContainsKey($_.FullName) } |
            Where-Object { Select-String -Path $_.FullName -Pattern ([regex]::Escape($NONCE)) -Quiet }
}
$MINE | Select-Object FullName, Length, LastWriteTimeUtc | Out-File "$OUT\precheck.attributed.txt"
$MINE | ForEach-Object { Copy-Item $_.FullName (Join-Path $OUT "precheck.$($_.Name)") }   # evidence FIRST
```

## Cleanup — bounded, attributed, evidence preserved first

Runs within `$CLEANUP_S` (30 s), a separate interval from the one launch deadline.

```powershell
$cleanup_end = (Get-Date).AddSeconds($CLEANUP_S)
Stop-Guarded $pid0 $st0 'cleanup' | Out-File "$OUT\precheck.kill.txt" -Append
# Remove ONLY files attributed to this attempt, and only after they are copied into $OUT above.
foreach ($f in $MINE) {
    if ((Get-Date) -ge $cleanup_end) {
        'CLEANUP-BOUND REACHED - remainder left in place' | Out-File "$OUT\precheck.cleanup.txt" -Append; break
    }
    if (Test-Path (Join-Path $OUT "precheck.$($f.Name)")) {
        Remove-Item $f.FullName -Force
        "REMOVED attributed $($f.FullName)" | Out-File "$OUT\precheck.cleanup.txt" -Append
    } else {
        "KEPT $($f.FullName) - evidence copy missing, refusing to delete" | Out-File "$OUT\precheck.cleanup.txt" -Append
    }
}
Remove-Item $WORK -Recurse -Force -ErrorAction SilentlyContinue    # created empty by this check
```

**Prohibited, per ruling, and absent from the commands above:** explicit trust or settings writes;
whole-config restoration; deletion of any pre-existing record. Everything listed in
`precheck.preexisting.txt` is never touched. Ordinary runtime transcript/session bookkeeping in the
shared credential root is **accepted for this one check**. Evidence is copied into `$OUT` **before**
anything is removed, and a missing evidence copy blocks the delete rather than the reverse. Each item
is reported as performed.

## 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 an attributed transcript | **evidence-unavailable** — NOT a NO. It lacks the independent witness that the relevant hook configuration loaded, which this plan itself requires; without that, absent output cannot establish non-firing. | — |
| 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 |
| an attributed `.jsonl` (new since the inventory, carrying the nonce) exists | — | **YES, to the narrower fact only: a transcript PERSISTED through termination.** That is persistence, **not** resumability — whether a resume actually succeeds is a later operation this check does not perform and must not be reported as having shown. |
| slug directory **exists**, no attributed `.jsonl` in it | — | **NO** — nothing persisted. A real negative on the persistence question only. |
| 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.

### Window classification — a third outcome class, not a relabelled void

Whether the kill landed while the turn was live is **its own axis**, independent of D1 and D2, with
three values, because neither trigger path can establish it:

| Class | When |
|---|---|
| `WINDOW-UNESTABLISHED` | the default. Tool-call evidence gives a *recording* time, which buffering may place after the live turn ended; the fallback deadline gives nothing at all. |
| `WINDOW-EVIDENCED` | only if some artifact actually attests the turn was live at termination. No such artifact is currently identified, so expect this to be unused. |
| `WINDOW-NOT-LIVE` | positive evidence the turn had already ended. |

A `WINDOW-UNESTABLISHED` case is **not** void and **not** a mid-turn kill: it is a case whose window
is unknown, reported as such. D2's answer still stands on its own evidence — a resumable id either
landed on disk or did not — but any statement about *mid-turn* kill semantics must carry this class.

**Counts, reported separately even for a single case:** scheduled 1 / attempted / evaluable /
unavailable, plus the window class. A check with unavailable evidence is reported as
evidence-unavailable, never as a negative.

## 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/`.
