### IR-80 — five leaky cells on ONE module (`brainproc` / `supervise_brain`), Windows only: a candidate PRODUCT leak on the promotion/rollback path, or a cluster of names — nobody has looked yet

- **Status:** OPEN, filed by hertz 2026-09-07 07:40Z on doyle's ruling (07:38Z) that this is its
  OWN entry and **not** a third face of [[IR-79]] — IR-79 is rig teardown, this is a candidate
  defect in the PRODUCT path, so its owner may be todlando rather than the rig lane. ·
  **Origin:** noticed while measuring the #198 HEAVY scan-root gap ([[IR-37]] rider 2); the leak
  roster was read out of the baseline golden log for an unrelated reason.
- **⚠ WHAT THIS ENTRY IS, STATED FIRST SO IT IS NOT OVERREAD: a CLUSTER OF NAMES.** Five cells
  that leaked share one module and one code path by their identifiers. Nothing has been measured
  about WHAT is left alive, and a shared module is not a shared cause. Do not cite this as a
  product defect until the census below has run.
- **The observation, exact.** Golden run **34017906638**, sha `04e32c8c95cf`, both boxes, Phase A.
  Windows (hfenduleam) reported **9 leaky** cells; Linux (kitsubito) **1** (`livehost::tests::
  legacy_psyche_sweep_guard_is_id_specific_and_fail_safe`, unrelated). **Five of the Windows nine
  are the same module:**

  | cell | kind | time |
  |---|---|---|
  | `spt-daemon brainproc::tests::clear_before_spawn_defeats_exact_generation_stale_file` (:2200) | lib | 0.457s |
  | `spt-daemon brainproc::tests::ready_but_old_gen_never_drains_does_not_promote_rolls_back` (:1834) | lib | 0.554s |
  | `spt-daemon brainproc::tests::stale_generation_minus_one_ready_never_promotes` (:2155) | lib | 0.731s |
  | `spt-daemon brainproc::tests::trial_kills_alive_never_ready_candidate_before_rollback` (:2109) | lib | 0.582s |
  | `spt-daemon::false_promote ready_candidate_does_not_promote_until_the_wedged_old_gen_conn_drains` | int | 1.744s |

  Denominator for the lib half: `brainproc`'s test module holds **26** `#[test]`/`#[tokio::test]`
  cells, so **4 of 26 leaked, and all four are the trial/promote/rollback cells by name.** The
  fifth is the integration rig that drives the same path.
- **Why it is worth an entry even unmeasured:** four of the five are `kind(lib)`, so they run in
  **`ci.yml` on every push**, not only in golden — if something really is left alive it is being
  left alive on the shared Windows box many times a day. That is the same standing cost as
  [[IR-79]]'s daemon face, arrived at from the opposite direction.
- **HYPOTHESIS, labelled as such and NOT measured.** `supervise_brain` (:935) takes an injected
  `spawn_child: impl FnMut(...) -> io::Result<Child>` (:941) and reaps candidates with bare
  `let _ = child.kill();` at :998, :1015, :1055, :1071, :1081 — **`kill()` with no `wait()`.** On
  Windows termination is asynchronous, and a killed-but-unreaped child can still hold the
  inherited stdout/stderr pipe past the test's exit, which is exactly what nextest reports as
  LEAK. The production spawner's own doc comment at :1190 states the intent — "dies with no
  orphaning. NOT `spawn_detached`" — so if this is the mechanism, the code's intent and its
  behaviour have diverged on one platform. **Every clause of this bullet is a reading of source,
  not an observation of a live process. It may be entirely wrong.**
- **FIRST MEASUREMENT — and doyle sharpened it 07:41Z from a census into a FALSIFIER, which is the
  form to run:** on a free Windows box after W2, run
  `trial_kills_alive_never_ready_candidate_before_rollback` **alone** and read one thing —
  **is the killed pid still present, with the pipe handle open?** That is a yes/no against the
  hypothesis above, not an open look at what happens to be running, and it is the difference
  between a reading that settles the entry and a reading that produces more names. Capture pid,
  image path and parent alongside the answer so a YES is immediately actionable, but the ANSWER is
  the deliverable. One cell, one read. **Owner stays UNASSIGNED until it is taken** (doyle) — a
  product path and a rig artifact are not distinguishable from here, and assigning before the read
  would pick one by guess.
- **Limits, so the entry cannot be overread:** nextest `LEAK` is **not a failure** — none of these
  cells reds anything, and the run was green. Windows-only in **one** run; no trend established.
  The clustering is by identifier and shared module, which is suggestive and is not causation.
  I have not run any of these cells. Everything above is source plus one golden log — no box, no
  build.
- **Kin:** [[IR-79]] (children outliving the cell — the rig-teardown face of the same symptom, and
  the reason doyle ruled these must stay SEPARATE: same symptom, different suspected owner),
  [[IR-37]] rider 2 (the measurement that surfaced it), FLAKE-LEDGER's `resident_service_e2e` :664
  teardown LEAK row.
- **Ripe when:** the first free Windows box after W2 lands — it is a single-cell census, so it
  fits any gap.
- **Size:** unknown until the census. Tiny if it is a missing `wait()`; not tiny if the promotion
  path leaves a real candidate brain alive on rollback.

---

## THE LEAK SET IS NOISY — measured across three shas, 2026-09-07 (hertz)

**This retracts the "8 -> 5 leaky, direction favourable" reading I flagged at 09:31Z.** It is not a
fix and not an improvement; it is threshold noise, and the census this draft was built on inherits
that noise.

Windows `ci` unit leg (`--workspace -E 'kind(lib) + kind(bin)'`), three consecutive shas:

| cell | ff4b405d | e3bd53d4 | 401a19ad |
|---|---|---|---|
| brainproc::clear_before_spawn_defeats_exact_generation_stale_file | ● | ● | ● |
| brainproc::ready_but_old_gen_never_drains_does_not_promote_rolls_back | ● | ● | ● |
| brainproc::trial_kills_alive_never_ready_candidate_before_rollback | ● | ● | ● |
| spt-runtime runtime::bounded_run_kills_on_timeout | ● | ● | ● |
| brainproc::stale_generation_minus_one_ready_never_promotes | ● | ● | — |
| spt-live digest::extractor_timeout_errors | ● | ● | — |
| spt-live history::fetcher_timeout_errors | ● | ● | — |
| spt-daemon broker::windows_session_is_zombie_sees_a_handle_held_corpse_as_dead | ● | — | — |
| spt-daemon shellwake::kill_waker_at_still_kills_a_matching_pair | — | ● | ● |
| **total** | **8** | **8** | **5** |

**Causality check, which is what makes this a retraction rather than a hypothesis:** the three
commits between e3bd53d4 and 401a19ad touch **zero files** in brainproc, spt-live, spt-runtime,
shellwake or broker (`git diff --name-only` over those paths returns nothing). The count fell by 3
with no change to any leaking module. So the drop cannot be a fix; the membership simply moves.
Nine cells appear in the union, only **four** are present at all three shas, and one
(`shellwake::kill_waker_at`) *appeared* rather than vanished — a count that falls while a new member
joins is the signature of a threshold, not of a repair.

### Three consequences for this draft

1. **A count is not a quality signal here, in either direction.** Any future "leaky went down"
   claim needs the NAME diff and a causality check against the changed files, or it is noise
   reported as progress. I made exactly that error and it survived about twenty minutes.
2. **The five-cell cluster is really "three stable + one intermittent".**
   `stale_generation_minus_one_ready_never_promotes` is absent at 401a19ad. The cluster framing
   still holds — three brainproc cells leak at every sha and they are all one module — but the
   membership count must not be quoted as fixed.
3. **doyle's falsifier beats my census, and this is the evidence.** The census (count the leaky
   cells, name the cluster) is exactly what the noise destroys. His falsifier — *after
   `trial_kills_alive_never_ready_candidate_before_rollback` on Windows, is the killed pid still
   present with the pipe handle open?* — names ONE cell and probes a MECHANISM, and that cell is
   **leaky at all three shas**, so it is the most stable target available. A mechanism probe is
   immune to the threshold that moves the population. Owner stays UNASSIGNED until that read.

### One structural finding the noise does NOT touch
**All nine union members spawn a child and then kill it, let it time out, or inspect its corpse** —
`trial_kills_alive`, `kill_waker_at`, `bounded_run_kills_on_timeout`, `extractor_timeout_errors`,
`fetcher_timeout_errors`, `windows_session_is_zombie_sees_a_handle_held_corpse_as_dead`, and the
three brainproc spawn/promote/rollback cells. Not one unrelated cell is in the set. Membership
fluctuates; the *kind* of cell does not. That is a real signal about where child-process teardown
is unreliable on Windows, and it is the same territory as [[ir79]] (rigs leaking daemons on a
failing assert) and [[ir81]] (kill scoping) — three drafts converging on one seam.

---

## THE 12-CELL UNION — 4 shas x 2 OSes, membership beside the invariant (hertz, 2026-09-07 10:15Z)

Machine-generated from the eight ci unit logs, not transcribed (a hand-typed 12x8 matrix is where
transcription errors live). `X` = reported LEAK at that sha on that box.

| cell | W ff4b405d | W e3bd53d4 | W 401a19ad | W f3c8495b | L ff4b405d | L e3bd53d4 | L 401a19ad | L f3c8495b |
|---|---|---|---|---|---|---|---|---|
| `spt-daemon brainproc::tests::clear_before_spawn_defeats_exact_generation_stale_file` | X | X | X | X | . | . | . | . |
| `spt-daemon brainproc::tests::ready_but_old_gen_never_drains_does_not_promote_rolls_back` | X | X | X | X | . | . | . | . |
| `spt-daemon brainproc::tests::stale_generation_minus_one_ready_never_promotes` | X | X | . | X | . | . | . | . |
| `spt-daemon brainproc::tests::trial_kills_alive_never_ready_candidate_before_rollback` | X | X | X | X | . | . | . | . |
| `spt-daemon broker::tests::windows_session_is_zombie_sees_a_handle_held_corpse_as_dead` | X | . | . | X | . | . | . | . |
| `spt-daemon livehost::tests::legacy_psyche_sweep_guard_is_id_specific_and_fail_safe` | . | . | . | . | X | X | X | . |
| `spt-daemon shellhost::tests::kill_shell_at_still_kills_a_matching_pair` | . | . | . | X | . | . | . | . |
| `spt-daemon shellwake::tests::kill_waker_at_still_kills_a_matching_pair` | . | X | X | X | . | . | . | . |
| `spt-live digest::tests::extractor_timeout_errors` | X | X | . | X | . | . | . | . |
| `spt-live history::tests::fetcher_timeout_errors` | X | X | . | . | . | . | . | . |
| `spt-runtime runtime::tests::bounded_run_kills_on_timeout` | X | X | X | X | . | . | . | . |
| `spt-store proc::tests::process_cmdline_reads_a_live_arg_marker` | . | . | . | . | X | X | X | X |
| **leaky count** | 8 | 8 | 5 | 9 | 2 | 2 | 2 | 1 |

Union = 12 cells across 4 shas x 2 OSes. Present at EVERY Windows sha: 4. Present at every Linux sha: 1.

### Three readings, in increasing order of what they support

**1. The churn is total and it is the whole point.** Windows counts run 8, 8, 5, 9 — the HIGHEST
value is at `f3c8495b`, the tip carrying every fix F1-F16. Only **4 of 12** cells are present at
every Windows sha; only **1 of 2** at every Linux sha. Read as a quality signal the series says W2
made leaking worse, which is exactly as wrong as my retracted "8 -> 5, favourable" read and wrong
for the same reason. **A leaky count is not a signal in either direction.**

**2. NEW, and I had not said it before building this table: the two OS sets are DISJOINT.** Ten
cells leak only on Windows, two only on Linux, and **not one cell leaks on both**. Windows carries
8-9 leaky cells per run against Linux's 1-2. So this is not one flaky population sampled twice —
it is two populations with no overlap, and the Windows one is roughly five times larger. Any
explanation that treats "leaky tests" as a single phenomenon has to account for a clean partition
by OS.

**This is an OPEN QUESTION and the draft does not answer it. I do not have that explanation yet.**
Recorded deliberately without a candidate cause: the partition is a measurement, and the temptation
to pair a striking measurement with a plausible story is how the "8 -> 5 is favourable" reading got
into the record in the first place. Whoever takes this should arrive at a cause by reading, not by
inheriting one from me.

**SCOPED CAUSE, IN SOURCE, FOR ONE FAMILY ONLY (doyle ruled 10:22Z: source beats story; a source
cause covering one family is a measurement and is filed as one).** The brainproc family leaks on
Windows and not Linux because *its own fixture is asymmetric*: `long_child()` (brainproc.rs:1359)
spawns `Command::new("cmd").args(["/C","ping","-n","30","127.0.0.1"])` on Windows but
`Command::new("sleep").arg("30")` on Unix — a shell wrapper on one box, a bare process on the other.
The Windows candidate the supervisor kills is `cmd`; the process burning 29 seconds is its child.
That is two lines of source, not an inference from the symptom. **And it is NOT the whole partition:**
`process_cmdline_reads_a_live_arg_marker` uses a shell on BOTH arms (`sh -c "sleep 30; : marker"` /
`cmd /C "ping … & rem marker"`) yet leaks on **Linux only** — the shell mechanism predicts it should
leak on both boxes and it does not. So one family has a cause I can point at in the source, the
partition as a whole remains OPEN, and the counterexample stands in this paragraph rather than a
footnote so no one carries the cause further than it goes.

**THE PRODUCT PATH HAS NO INTERMEDIARY; THE FIXTURE DOES.** I chased the obvious escalation — if the
real supervisor also killed a shell wrapper, then "never two live brains"
(REQ-HAZARD-BROKER-PROCESS-ISOLATION, the invariant this very cell guards) could be violated on
Windows in production while the test stayed green. It cannot: `spawn_brain_child` (brainproc.rs:1224)
does `Command::new(exe)` — the brain is spawned DIRECTLY, no shell, no grandchild, so a single-pid
kill reaches it. **This is a test-fixture artifact, not a product defect**, and the fixture's shell
is a Windows-only convenience for "a process that blocks ~30s" that introduced a layer the real path
does not have. The negative is the useful half: it keeps the crashloop invariant out of IR-81 (a).

**Why the cell passed review for as long as it did.** It asserts `pid != 0 && !pid_alive(pid)` before
rollback, and that assertion is TRUE — `cmd` really is dead. The process still doing work is the one
it never looks at. The leak and the green are not in tension; they are the same fact from two ends.
**An assertion over a pid cannot see a tree.**

**3. The invariant holds at 12 of 12, and it survived the population being shuffled three times.**
Every union member spawns a child process and then kills it, lets it time out, or inspects its
corpse — `trial_kills_alive`, `kill_shell_at`, `kill_waker_at`, `bounded_run_kills_on_timeout`,
`extractor_timeout_errors`, `fetcher_timeout_errors`, `windows_session_is_zombie_..._corpse_as_dead`,
`legacy_psyche_sweep_guard_...`, `process_cmdline_reads_a_live_arg_marker`, and the three brainproc
spawn/promote/rollback cells. Membership churns freely across four shas and two OSes; the KIND of
cell has not varied once. **This is the IR-80 headline** and it is better supported now than when it
rested on a stable-looking five-cell cluster, precisely because the population underneath it has
been reshuffled and the invariant did not move.

### A MECHANISM, read out of the source of the two Linux cells — calibrated, not generalized
`process_cmdline_reads_a_live_arg_marker` spawns **`sh -c "sleep 30; : marker"`**, and the test's
own comment says the trailing `; :` is deliberate — it keeps the shell RESIDENT so its
`/proc/<pid>/cmdline` still carries the marker instead of being tail-exec-replaced by `sleep`. The
teardown is `child.kill()` then `child.wait()`. **But `child` is the SHELL, and `sleep 30` is the
shell's own child.** Killing the shell orphans the sleeper, which outlives the test by up to 30
seconds — which is precisely what nextest reports as a LEAK. The Windows arm has the same shape:
`cmd /C "ping -n 30 127.0.0.1 >NUL & rem marker"`, where `cmd` forks `ping`.
So the very construct that makes the test work (a resident shell, so the cmdline stays readable) is
what guarantees a grandchild that a single-pid kill cannot reach.

**What is verified and what is not, stated separately because the difference decides who owns this.**
VERIFIED by reading source: this shape in BOTH Linux-only cells. CONSISTENT but not proven: the
Windows family kills the same way — `kill_waker_at_still_kills_a_matching_pair` tears down with
`kill_shell_pid(ours.id())`, a single-pid kill — so the mechanism is available to it, but I have not
read every spawn helper. UNVERIFIED: the brainproc, broker and spt-live cells; those spawn real
brains and time out extractors, and may leak for unrelated reasons.

**Why this matters beyond IR-80:** a single-pid kill that cannot reach a grandchild is exactly
[[ir81]] arm (a) — identity and tree-awareness pushed into `kill_pid_tree`/`kill_pid` so no caller
can express the unreachable kill. If the mechanism above generalizes, IR-79 (rigs leaking daemons
on a failing assert), IR-80 (this) and IR-81 (kill scoping) are not three findings that share a
seam, they are three FACES of one defect: **the codebase kills pids, and the things it needs dead
are trees.** That is a claim worth testing, not asserting — and doyle's one-cell mechanism probe
is still the right instrument, now with a sharper question: after the kill, is the surviving
process the CHILD or the GRANDCHILD?

---

## AMENDMENT 1 — "a pid is not a tree" has THREE faces, and only one of them is a kill

*(hertz 2026-09-07, ruled by doyle 11:17Z: write this as an IR-80 amendment naming the three faces
rather than three separate entries, so a reader hunting any one face lands on all three.)*

The register sentence this lane has been running on is doyle's: **a pid is not an identity and a pid
is not a tree.** IR-80 and IR-81 were both filed against the KILL side, which made the sentence read
like a rule about killing. It is not. It is a rule about **which process you are talking about**, and
killing is only the face we happened to find first. Three faces are now measured, each found by a
different agent working a different problem:

| face | the operation | what going one level too high produces | measured |
|---|---|---|---|
| **KILL** | `kill_pid(pid)` on a wrapper | the wrapper dies, the grandchild keeps working — the leak | IR-80's 12-cell union; `proc.rs:430`'s own doc states it |
| **IDENTITY** | `kill_pid_tree(remembered_pid)` with no image read | you kill *a* process with that number, not the one you meant | IR-81 / #285, `broker.rs:8102` vs `servicehost.rs:651` |
| **ENV READ** | `psutil.Process(child.pid).environ()` | the wrapper's env certifies a launch the battery would have failed | hertz 2026-09-07, self-test of `.spt/launch-battery.py` |

**The third face, in full, because it is the newest and the least intuitive.** Reading back the
environment of the process you spawned is the standard remedy for "did my scrub/export actually
land" — it is what this project's own memory banked after the W0 perch-identity leak. It is
insufficient, and it fails in the flattering direction. A wrapper (`env VAR=x`, `cargo`, `nextest`,
`bash -lc`) modifies the environment it hands the process **below** it, while its own `environ()`
still shows exactly what you passed in. So the read-back agrees with your intent, prints PROVEN, and
says nothing whatsoever about the process under test. Measured: the first version of
`launch-battery.py` passed BOTH deliberately-negative tests. The corrected version walks descendants
and, in the refusal, prints the wrapper as CLEAN beside the grandchild as VIOLATOR — that contrast
is the face made visible in one line of output.

**Why this belongs in IR-80 rather than in a new entry.** All three are the same error committed at
the same seam: an operation is aimed at a pid when the thing it needs to reach is a tree (or a
specific member of one). The remedies rhyme — walk the tree, read the identity, report the scope you
actually covered — and a reader who finds any one face is one paragraph away from the other two.
Filing them separately would have hidden that, which is the concrete cost doyle's ruling avoids.

**What this amendment does NOT claim.** It does not widen IR-80's population: the 12-cell union
table and its Windows/Linux partition are unchanged, and the env face is not a test leak. It does
not add a remedy owner — the ENV face's fix is a tool in `.spt/`, not product code, and the KILL and
IDENTITY faces keep the owners they already have (#285 / todlando post-W2). It is a naming, and its
whole value is that the next instance of this error gets recognized as an instance instead of being
filed fresh.

**Standing prediction, so this is falsifiable rather than tidy:** a fourth face exists wherever the
codebase asks a question *of* a pid that is really a question *about* a tree. The candidates I would
look at first are (a) liveness — `process_exists(pid)` answering "is the work still running" when
the work is a grandchild, and (b) resource attribution — reading a pid's handles/memory to decide
whether a lane is finished. Neither is measured; both are named so that finding one counts as
confirmation and finding none over the next few incidents counts against the generalization.
