# The launch-identity correction — narrow design, diff and control plan (r2)

hertz, 2026-09-13, for doyle. r1 answered JDCJYANF; **this r2 is rewritten under doyle's three
NRTFH236 constraints** — the r1 text is preserved at `history/LAUNCH-IDENTITY-DESIGN-r1-*.md` and is
superseded, not annotated. **Nothing here is applied.** The pinned driver `fp-driver-d2-r2.sh`
(`67913bd6…`) and the accepted `fp-driver-d2-r3.sh` (`48f1afcf…`) are unchanged; a successor (r4)
would be built by an anchored builder. **No process-launch control has run since the pause.**

## 0. What r2 changes, and why

| NRTFH236 constraint | what r1 said | what r2 says |
|---|---|---|
| 1. `Start-Process`'s object identifies the **direct child only**. Persist identity METADATA; the launcher RETAINS the handle and never exports it | called the file a "handle file" | the file is an **identity record** (exe, pid, creation instant, native exit). The launcher holds `$p` for its whole life and writes no handle. The record's authority stops at the **direct child** |
| 2. A best-effort descendant scan **cannot** establish COMPLETE coverage. Without ENFORCED containment, whole-operation coverage stays PARTIAL and the direct subject's exit is reported SEPARATELY | had `coverage=COMPLETE` as a reachable state, gating `CONFIRMED_GONE` | **`COMPLETE` is not emissible.** Coverage is `PARTIAL` or `NONE` only, so `CONFIRMED_GONE` is unreachable for a whole operation. The direct subject's own disposition is reported **separately**, as `subject_state=`, and never summed with descendants |
| 3. REMOVE env/timeout intermediaries from the native launch boundary; "no call-site changes" is WITHDRAWN | claimed "no call site changes" | the intermediaries go. `timeout` leaves `bounded()`, `env` leaves `:1239`. **Affected call sites change**, and they are listed in §4 |

Scope of what may RUN: **L0/L1 only**, after todlando's #299 window closes. Short self-exiting
throwaway subjects, native and msys. **No tree kill in the discriminator**; L2–L7 and every
termination/containment control are HELD until containment behaviour is reviewable; no PID-tree
sweep may target an unknown descendant.

## 1. The defect, as measured

| arm | evidence |
|---|---|
| A7 (`20260913T092001Z-A`) | subject was `pwsh -NoProfile -Command 'Start-Sleep 45'`. The register's ONLY `kind=native` row reads `exe=[C:\Program Files\Git\usr\bin\timeout.exe]` — **the wrapper** |
| A6 (`20260913T091640Z-A`) | subject was msys `sleep`. NO native row at all: `ATTRIBUTION_INCOMPLETE reason=no-child-at-registration` |
| probe (`probe-a3-parent`) | msys `sleep.exe` winpid 52104 reported Windows ppid 43640 while its msys parent `timeout.exe` was winpid 52040 — **the Windows parent chain does not follow the msys one** |
| disposition (`disposition-20260913T092245Z`) | all 5 rows carrying a pid are `timeout.exe`; all 5 measured GONE. The subjects those wrappers bounded have NO identity and stay UNREADABLE |

**Mechanism.** `winpid_of()` reads msys `ps` field 4 for the backgrounded job — on cygwin that is
the pre-exec stub's winpid. `register_launched()` then asks Win32 for `ParentProcessId=<that
winpid>`, and the only thing that answers is the exec'd `timeout.exe`. So the deepest identity the
register can reach **is the wrapper**, and `measure_termination()` re-queries the wrapper.

**Consequence.** `TERMINATION=CONFIRMED_GONE` is a statement about `timeout.exe`. A surviving
subject is invisible, and the ledger prints CONFIRMED_GONE on the wrapper's disposition alone.

## 2. What the fix must NOT be

**Not a deeper traversal** (doyle JDCJYANF). Walking one more level down the same Win32 parent
links inherits the same broken premise: the links are garbled for cygwin-forked processes, the
depth that holds the subject differs per call site, and a race at registration still returns an
empty answer that a depth-walk cannot distinguish from "no child".

**And not a scan that certifies itself** (doyle NRTFH236 #2). Enumerating descendants and finding
none is the absence of a reading. No scan result — clean, empty, or full — may raise coverage.

## 3. The design — the launcher is the authority over exactly one process

### A. `launch_bounded()` replaces `timeout … & / winpid_of / descendant query`

The subject's identity comes from the act of launching it, not from a query afterwards. One native
launcher creates the child, **keeps the object for its whole life**, and enforces the bound itself:

```
# AS BUILT in r4's bounded(). The program is resolved to a Windows path at the CALL (win_exe),
# and the ARGUMENTS CROSS ON DISK, one per line: an array handed through `pwsh -File` collapses
# into a single string, which is how quoting defects are built.
pwsh -NoProfile -File "$(cygpath -w "$BIN/launch_bounded.ps1")" \
     -Label "$label" -Seconds "$left" \
     -RecordFile "$(cygpath -w "$rec")" -ArgsFile "$(cygpath -w "$argf")" \
     -Exe "$lwin" \
     -OutFile "$(cygpath -w "$out")" -ErrFile "$(cygpath -w "$err")" \
     > "$R/.launch-log.$myseq" 2>&1     # the LAUNCHER's own streams, never the subject's
```

`launch_bounded.ps1` (new instrument, reviewed like every other):

```
$p = Start-Process -FilePath $exe -ArgumentList $rest -PassThru -NoNewWindow `
     -RedirectStandardOutput $Out -RedirectStandardError $Err
# IDENTITY METADATA, written BEFORE any wait. No handle is written: $p stays in this process.
"subject pid=$($p.Id) created=$($p.StartTime.ToUniversalTime().ToString('o')) exe=$($p.Path)"
  | Set-Content -LiteralPath $RecordFile
if (-not $p.WaitForExit($Seconds * 1000)) { <termination — HELD, see §3E> }
else { "subject_state=EXITED native_exit=$($p.ExitCode)" | Add-Content -LiteralPath $RecordFile }
```

Three properties this buys, and no fourth:

1. **The pid is not inferred from a table.** `-PassThru` returns the object for the process this
   call created; `StartTime` is the creation anchor off that same object.
2. **The pid cannot be recycled out from under the record** while the launcher lives, because the
   launcher holds the handle. That is why the handle is retained and why it is never exported —
   a handle in a file is a number that has already stopped meaning anything.
3. **The exit code is the subject's own**, read from the retained object, not inherited through a
   wrapper's exit convention.

What it does **not** buy: any statement about anything the subject itself started. The object
identifies the **direct child only**.

### B. The register's vocabulary — intermediaries are a KIND, never a subject

```
LAUNCHED seq=N label=L kind=launcher   pid=… created=…             # the launcher itself
LAUNCHED seq=N label=L kind=subject    pid=… created=… exe=…       # from the identity record
#   NO kind=descendant ROW IS EMITTED BY r4. Nothing enumerates descendants any more: such
#   a scan could not certify coverage (constraint 2), and its clean answer is the absence
#   of a reading rather than a reading of an absence.
LAUNCHED seq=N label=L coverage=PARTIAL|NONE reason=…              # one row per launch
LAUNCHED seq=N label=L subject_state=EXITED|KILLED|UNREADABLE native_exit=…
```

A row whose `exe` is a known intermediary (`timeout.exe`, `env.exe`) may only be `kind=launcher`.
If the only identity a launch produced is a launcher row, `coverage=NONE`.

### C. Coverage, under constraint 2 — `COMPLETE` does not exist

There is no enforced containment (no job object, no kill-on-close), so **no launch can report
complete coverage of the operation it started**, and the successor must be incapable of saying it:

```
coverage=PARTIAL  reason=direct-subject-only-no-enforced-containment   # the normal case
coverage=NONE     reason=<why no identity was recorded>
```

`measure_termination()` therefore reports **two things that are never averaged together**:

```
subject_state=  EXITED|KILLED|SURVIVING|UNREADABLE   # the DIRECT subject, by pid + creation anchor
coverage=       PARTIAL|NONE                          # the operation as a whole
whole_operation = ATTRIBUTION_INCOMPLETE              # ALWAYS, while coverage < COMPLETE
```

`CONFIRMED_GONE` becomes **unreachable for a whole operation** under this design. A clean
descendant scan adds `kind=descendant` rows and changes no verdict. The ledger's `residual_state`
inherits that: `ATTRIBUTION_INCOMPLETE` can never reach CLEAR.

This is a deliberate loss of an answer the driver used to give. It gave it wrongly.

### D. The historical limitation is preserved, not repaired (doyle 64YIGUUN)

Future registration only. The five wrapper identities already measured GONE stay exactly that, and
the subjects they bounded stay UNREADABLE for good: no fix recovers an identity never recorded.

### E. Termination and containment are HELD

The r1 draft had the launcher call `$p.Kill($true)` on expiry. **That is withdrawn from this step.**
Tree kill is containment behaviour and it is not reviewable yet, so r4 will:

* on expiry, record `subject_state=UNREADABLE reason=bound-expired-no-termination-authorized` and
  return the expiry rc, **terminating nothing**;
* contain nothing, sweep nothing, and target no descendant pid.

An enforced-containment step (job object with kill-on-close, which is what would make
`coverage=COMPLETE` mean something) is a **separate proposal** after this one is measured.

## 4. The diff, scoped — call sites DO change (constraint 3)

| site | change |
|---|---|
| `bounded()` :979-1015 | the `timeout … & / winpid_of / wait` block becomes `launch_bounded` plus a read of the identity record. **`timeout` is gone from the native launch boundary**: the launcher owns the clock. Bound arithmetic, `BOUNDED_STATE`, the exits rows and the OUT/ERR parameters are untouched |
| `register_launched()` :860-908 | reads the identity record instead of querying `ParentProcessId`; emits `kind=launcher`/`kind=subject`; any descendant query becomes best-effort, only ever ADDS `kind=descendant`, and cannot raise coverage; writes the `coverage=` row |
| `measure_termination()` :909-953 | reports `subject_state` for the direct subject by pid + creation anchor, and `whole_operation=ATTRIBUTION_INCOMPLETE` while coverage < COMPLETE. Emits no `CONFIRMED_GONE` |
| `winpid_of()` :852-859 | **REMOVED.** Its only consumer was the descendant query, and the mapping it supplied is the very reading that misattributed the subject. Leaving it behind would leave the next author a tool whose answer looks authoritative and is not |
| `capture_run_identities()` **:1239** | **the `env SPT_D2_HOME=… pwsh …` interposition is removed.** The variable is passed to the launcher, which sets it in its own environment before `Start-Process`, so the child inherits it directly and the intended executable IS the direct child |
| :484, :1131, :1688, :2301, :2313, :2427, :2437 | unchanged in shape — they already name a native executable directly; they gain the new boundary by way of `bounded()` |
| new | `fp-bin/launch_bounded.ps1` |

No budgets change, no gate is relaxed.

## 5. The control plan — L0/L1 only, and the discriminator first

**Scope now:** L0 and L1 only, and not until todlando reports END on his #299 window. Throwaway
self-exiting subjects, native and msys. No tree kill, no termination arm, no descendant sweep.
L2–L7 stay written down and HELD.

| arm | what it establishes | status |
|---|---|---|
| **L0** | RAN, FAILED AS REQUIRED (exit 1). NEGATIVE CONTROL, offline: the anchor check is handed a **live** pid with a **deliberately skewed** creation instant and the arm asserts ANCHORED. It must FAIL (exit 1). This is also the anchor predicate's positive control: a check that ignores the creation field passes here, and passing here is a defect | RUNS |
| **L1a** | RAN, PASS. The discriminator, native: does `Start-Process -PassThru` give a pid that Win32 re-queries by **pid + creation anchor**, with `exe` naming the SUBJECT (not a wrapper), and does the retained object return the subject's own exit? **The design rests on this measurement.** Failure here refutes the design | RUNS |
| **L1b** | the same four predicates for an **msys** subject (`sleep.exe`) launched directly. The outcome was genuinely open and the arm asserted only that the measurement completed. **MEASURED: it anchors** — see §6 | RAN, PASS |
| L2 | `kind=subject` names the subject's exe at a real call site and the exit is verbatim | HELD |
| L3 | a child that outlives its bound | **HELD — termination is not authorized** |
| L4 | the `:1239` shape with the intermediary removed | HELD |
| L5 | the coverage gate over a real descendant | HELD |
| L6 | the ledger arm: coverage < COMPLETE cannot produce `residual_state=CLEAR` | HELD |
| L7 | read-only disposition of every identity L0–L1 registered, by anchor — no name matching, no termination | RUNS (end of group) |

Standing rules unchanged: new output dir per arm, existing destination refused, failures preserved,
negative control first and it must fail with **exit 1** (a harness error is not a failed assertion).

## 6. The measurement — L0/L1 RAN, 2026-09-13 (`controls-out/20260913T100902Z-L`)

Group L ran after todlando closed his #299 window (END 10:08:51Z). Four arms, `bad=0`, probe
`launch-identity-probe.ps1` sha256 `bfd6d70b…`, harness `group-l.sh` sha256 `2a895ec3…`.

| arm | verdict | what it measured |
|---|---|---|
| L0 | **failed at exit 1, as required** | positive control first: unskewed, the predicate read `ANCHORED=YES` with `anchor_delta_ms=0`. Then, with the recorded anchor skewed 3600s over the same live pid, it read `anchor_match=NO ANCHORED=NO` — so the arm's assertion of ANCHORED failed. A pid-only check would have passed this arm |
| L1a | **PASS — native direct-child identity ESTABLISHED** | `Start-Process -PassThru` → pid 53072; Win32 re-queried it by pid AND creation anchor at `delta 0 ms`; `exe` and `cmdline` named the SUBJECT (`pwsh.exe … -Command Start-Sleep -Seconds 8`), not an intermediary; `subject_state=EXITED native_exit=0` came off the retained object |
| L1b | **PASS — an msys subject launched directly ANCHORS TOO** | `sleep.exe 8`: `pid_match=YES anchor_match=YES exe_match=YES ANCHORED=YES delta_ms=0`, `subject_state=EXITED`. The reserved "msys stays PARTIAL by construction" caveat is **not needed** |
| L7 | PASS | read-only disposition of all four identities. **pid 53072 was RECYCLED 24 seconds after its subject exited** (`now_created=10:09:37.223`), and the anchor caught it: a pid-only re-query would have reported that subject SURVIVING. Nothing was terminated |

**The design's premise holds, and one of its caveats is now measured away.** Coverage stays
`PARTIAL` for the reason that has nothing to do with msys: no enforced containment exists.

## 7. Status of the successor

`fp-driver-d2-r4.sh` is built from the ACCEPTED `fp-driver-d2-r3.sh` by the anchored builder
`build-driver-r4.py` (12 anchored edits, each refusing on drift). It **parses** (`bash -n`) and it
is **UNEXERCISED**: exercising `bounded()` end to end needs an L2 arm, and L2 is HELD under the
current scope. It is applied nowhere and no run consumes it.
