# 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.

**Attribution narrows it further, and possibly to a single value.** D1 = YES now requires an
artifact *attributable to this submission*; a trace line in the shared time slice is corroboration,
not attribution. The attributable artifact would be a `UserPromptSubmit` hook attachment inside the
attributed transcript — and whether a non-perched print-mode spawn produces one is exactly the thing
that is UNMEASURED. **So the realistic answer space may be {evidence-unavailable} alone.** That is
said plainly so the check is admitted for what it can actually return: it would still establish, from
one launch, whether any attributable hook artifact exists at all, which is the fact every later
design choice rests on.

**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.

## Stub results — argv and termination, exercised before requesting the launch

Run on this node, harmless stubs only, **no Claude launch and no host fault induced**. Scripts:
`scratchpad/stubtest.ps1` (argv + real-process terminator) and `scratchpad/injecttest.ps1`
(terminator failure branches via an **injected throwing process-query** — the query is a parameter
of the terminator, so the fault lives in the test and never in the host).

| Test | Result |
|---|---|
| **`Start-Process -ArgumentList`** (the construction in `04181e5`) | **BROKEN, measured.** The 4-element vector arrived as **16 arguments** — `-p`, `Run`, `one`, `shell`, `command`, `that`, … The prompt was shredded word by word. |
| **`ProcessStartInfo.ArgumentList`** (the fix) | **EXACT VECTOR MATCH, 4/4**, element-by-element, case-sensitive. |
| `Stop-Guarded` on a live process | `CONFIRMED-EXIT` after post-stop re-query |
| `Stop-Guarded` on an already-exited process | `ABSENT` |
| `Stop-Guarded` with a mismatched creation time | `IDENTITY-MISMATCH`, **and the process was verified still running** — it was not killed |
| **`QUERY-FAILURE`** (pre-stop query throws a non-absence fault) | **PASS, fault-injected.** Classified `QUERY-FAILURE`; residual identity preserved in the record; **no false `CONFIRMED-EXIT`**; and the process verified **not killed**. |
| **`EXIT-UNCERTAIN`** (pre-stop query OK, post-stop query throws) | **PASS, fault-injected.** Classified `EXIT-UNCERTAIN`, not `CONFIRMED-EXIT`; residual identity preserved; a query-invocation count of 2 proves both sides of the stop were exercised. |
| Control: default (real) query after injection | **PASS** — still classifies `CONFIRMED-EXIT`, so the injection harness did not mask normal behaviour. |

So the argv blocker was real and is now fixed by construction rather than by assertion, and the
earlier "argv proof" would have been proving the broken construction against itself.

## Required runtime — pinned, because the fix depends on it

**The precheck must run under PowerShell 7+ (`pwsh`), the same runtime as the successful argv test.**

| | |
|---|---|
| Verified runtime | **PowerShell 7.6.6, PSEdition Core, .NET 10.0.12** |
| `ProcessStartInfo.ArgumentList` | **present** |

`ArgumentList` is a modern-.NET member and is **absent from Windows PowerShell 5.1's .NET Framework**.
What follows from that is only this: **the construction this plan relies on is not available there**,
so the plan is supported on a modern-.NET parent and nowhere else. The commands **assert the runtime
and refuse to launch** otherwise, rather than trusting whichever shell they are pasted into.

*Correction, kept because I asserted more than I measured:* an earlier revision said running under 5.1
would "silently restore" the argument shredding. That claims a **fallback path** — code quietly
degrading to the joined-string form — and I never measured one. Absence of the member does not by
itself imply silent fallback. The requirement and the refusal guard stand on their own; the failure
mode does not, and is withdrawn rather than left in as colour.

(The argv stub's *child* is Windows PowerShell, deliberately and harmlessly: it only echoes its argv,
and the vector arriving intact across a different runtime is a stronger result, not a weaker one.
What must be modern is the **parent** that builds the vector.)

## Commands, as they will be run

```powershell
# --- setup -------------------------------------------------------------------
$ErrorActionPreference = 'Stop'

# RUNTIME GATE - the argv fix does not exist on Windows PowerShell 5.1 (.NET Framework).
if ($PSVersionTable.PSEdition -ne 'Core') { throw "requires PowerShell 7+ (pwsh); found $($PSVersionTable.PSVersion) $($PSVersionTable.PSEdition)" }
if (-not ([Diagnostics.ProcessStartInfo]::new() | Get-Member -Name ArgumentList)) { throw 'ProcessStartInfo.ArgumentList missing - wrong runtime, refusing to launch' }

$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, process cleanup only
$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

# WORK must be created BY THIS ATTEMPT. A pre-existing WORK is refused rather than reused, so
# nothing here can adopt - or later disturb - a directory somebody else owns.
if (Test-Path $WORK) {
    "WORK already exists: $WORK - refusing to reuse or clean it" | Out-File "$OUT\precheck.ABORT.txt"
    throw 'pre-existing WORK - aborting'
}

# RE-VERIFY the trusted chain 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: BUILT correctly, then PROVEN exactly against a harmless stub -------
# ProcessStartInfo.ArgumentList escapes per element. Start-Process -ArgumentList re-joins and was
# MEASURED to shred this prompt into 16 arguments (see Stub results), so it is not used anywhere.
$PROMPT = "Run one shell command that prints exactly $NONCE, then reply with exactly $NONCE"
$ARGS_V = @('-p', $PROMPT, '--output-format', 'json')

function Start-Exact([string]$exe, [string[]]$argv, [string]$stdout, [string]$stderr) {
    $psi = [Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = $exe
    foreach ($a in $argv) { $psi.ArgumentList.Add($a) }   # per-element escaping; no join
    $psi.WorkingDirectory = $WORK
    $psi.UseShellExecute = $false
    $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true
    $pr = [Diagnostics.Process]::Start($psi)
    return $pr
}

$stub = "$OUT\argv-stub.ps1"
Set-Content -Path $stub -Encoding UTF8 -Value '$i=0; foreach ($a in $args) { [Console]::Out.WriteLine("<<$i>>$a<<END>>"); $i++ }'
$sp = Start-Exact 'powershell' (@('-NoProfile','-File',$stub) + $ARGS_V) $null $null
$stubOut = $sp.StandardOutput.ReadToEnd(); $sp.WaitForExit()
$stubOut | Out-File "$OUT\precheck.argv.txt"
$recv = @()
foreach ($line in ($stubOut -split "`r?`n")) { if ($line -match '^<<(\d+)>>(.*)<<END>>$') { $recv += $Matches[2] } }
# EXACT vector equality - count and every element, case-sensitive. Not a substring match.
$ok = ($recv.Count -eq $ARGS_V.Count)
if ($ok) { for ($i=0; $i -lt $ARGS_V.Count; $i++) { if ($recv[$i] -cne $ARGS_V[$i]) { $ok = $false } } }
if (-not $ok) {
    "ARGV PROOF FAILED expected=$($ARGS_V.Count) received=$($recv.Count)`n$($recv -join "`n")" |
        Out-File "$OUT\precheck.ABORT.txt"
    throw 'exact argv not proven - not launching'
}

# --- pre-launch inventory: whatever already exists is NOT ours ----------------
New-Item -ItemType Directory -Force -Path $WORK | Out-Null   # created BY THIS ATTEMPT (refused above if present)
$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: absence, identity mismatch, query failure and confirmed exit
# all kept distinct - BEFORE the stop and AFTER it. A post-stop query failure yields
# EXIT-UNCERTAIN, never CONFIRMED-EXIT.
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_start=$($start.ToString('o')) - NOT killed, identity preserved" }
    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; $afterFailed = $false
    try { $after = Get-Process -Id $id -ErrorAction Stop }
    catch [Microsoft.PowerShell.Commands.ProcessCommandException] { $after = $null }
    catch { $afterFailed = $true }
    if ($afterFailed)                            { return "EXIT-UNCERTAIN $label pid=$id - post-stop query FAILED; exit NOT confirmed" }
    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'))"
}

# Attribution, used identically by the TRIGGER and by the HARVEST.
function Get-AttributedTranscripts {
    if (-not (Test-Path $PROJDIR)) { return @() }
    return @(Get-ChildItem "$PROJDIR\*.jsonl" -ErrorAction SilentlyContinue |
        Where-Object { -not $PRE.ContainsKey($_.FullName) } |
        Where-Object { Select-String -Path $_.FullName -Pattern ([regex]::Escape($NONCE)) -Quiet })
}

# --- the one launch, with cleanup in finally ---------------------------------
$pid0 = $null; $st0 = $null; $firstError = $null; $MINE = @()
try {
    $p = Start-Exact 'claude' $ARGS_V $null $null
    $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"

    # Trigger: ATTRIBUTED transcripts only - same nonce rule the harvest uses, so a prior or
    # unrelated session can never serve as trigger evidence.
    # NEITHER PATH ESTABLISHES A LIVE TURN: this is a RECORDING time, and buffering may place the
    # observation after the live turn ended. The bound firing says nothing about turn state at all.
    $why = 'trigger-bound-reached'
    $triggerEnd = $LAUNCH_AT.AddSeconds($TRIGGER_S)
    while ((Get-Date) -lt $triggerEnd -and (Get-Date) -lt $DEADLINE) {
        if (Get-AttributedTranscripts | Where-Object { Select-String -Path $_.FullName -Pattern '"type"\s*:\s*"tool_use"' -Quiet }) {
            $why = 'attributed-tool-evidence-recorded'; break
        }
        Start-Sleep -Milliseconds 400
    }
    "KILL-TRIGGER path=$why at=$((Get-Date).ToString('o')) window=UNESTABLISHED" |
        Out-File "$OUT\precheck.killtrigger.txt"
    Stop-Guarded $pid0 $st0 'trigger' | Out-File "$OUT\precheck.kill.txt"

    $remain = [int][Math]::Max(0, ($DEADLINE - (Get-Date)).TotalSeconds)   # SAME deadline, never a fresh one
    if ($remain -gt 0) { Wait-Process -Id $pid0 -Timeout $remain -ErrorAction SilentlyContinue }

    # --- harvest: rotation-aware, attributed ---------------------------------
    $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()
    }
    $MINE = Get-AttributedTranscripts
    $MINE | Select-Object FullName, Length, LastWriteTimeUtc | Out-File "$OUT\precheck.attributed.txt"
    $MINE | ForEach-Object { Copy-Item $_.FullName (Join-Path $OUT "precheck.$($_.Name)") }   # evidence copied, originals RETAINED
}
catch {
    $firstError = $_                     # preserved; never masked by a cleanup failure
    "FIRST-ERROR $($_.Exception.Message)" | Out-File "$OUT\precheck.error.txt"
}
finally {
    # Process cleanup ALWAYS runs, even if the harvest or a file read threw above.
    $cleanup_end = (Get-Date).AddSeconds($CLEANUP_S)
    try {
        if ($pid0) {
            $r = Stop-Guarded $pid0 $st0 'cleanup'
            $r | Out-File "$OUT\precheck.kill.txt" -Append
            if ($r -match '^(QUERY-FAILURE|EXIT-UNCERTAIN|KILL-UNCONFIRMED)') {
                "RESIDUAL pid=$pid0 start=$($st0.ToString('o')) - exit NOT confirmed; no artifact removed" |
                    Out-File "$OUT\precheck.residual.txt"
            }
        }
        if ((Get-Date) -ge $cleanup_end) { 'CLEANUP-BOUND REACHED' | Out-File "$OUT\precheck.cleanup.txt" -Append }
    }
    catch { "CLEANUP-FAILURE $($_.Exception.Message)" | Out-File "$OUT\precheck.cleanup.txt" -Append }
    if ($firstError) { throw $firstError }   # the original failure is what surfaces
}
```

## Cleanup — process only. Nothing is deleted.

**No file is removed by this check.** `WORK` is retained, and so are the transcripts, after their
evidence copies land in `$OUT`. That removes the whole class of risk doyle named: there is no
recursive delete to get wrong, nothing pre-existing can be destroyed, and **no artifact is ever
removed while process exit is uncertain** — because no artifact is removed at all. A single check
does not need the tidying.

| Item | Action |
|---|---|
| The spawned process | Terminated under `Stop-Guarded`, inside `finally`, within a 30 s bound |
| `QUERY-FAILURE` / `EXIT-UNCERTAIN` / `KILL-UNCONFIRMED` | Recorded in `precheck.residual.txt` with the PID and creation time, for follow-up |
| `WORK` | **Retained** (created by this attempt; a pre-existing one aborts the run) |
| Transcripts under the slug | **Retained**; copies preserved in `$OUT` |
| Pre-existing records | **Never touched.** Listed in `precheck.preexisting.txt` |
| Trust / settings / whole-config | **Never written, never restored** |

Every item is reported as performed rather than asserted as a whole. Retained artifacts are listed so
a later cleanup, if anyone wants one, is a separate decision with its own evidence.

## 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 |
|---|---|---|
| a `UserPromptSubmit` hook artifact **attributable to this submission** (a `hookName` attachment inside the attributed transcript) | **YES** — print mode reaches the surface for *this* submission | — |
| a `BEGIN UserPromptSubmit` line in the time slice but **nothing attributable to this submission** | **evidence-unavailable.** The trace line carries only the hook process pid and `id=-`; it does not name this submission, and the slice is shared with every other session on the node. Corroboration, never attribution. | — |
| 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/`.
