#314 — read-only RCA: an adapter label is being used as a process-topology guarantee Analyst: hertz Issue: https://github.com/BigscreenVR/spt-bs-releases/issues/314 Scope: preserved W1b run and source/contract analysis. No product edits, new daemon launches, or resident mutations. CONCLUSION The shared zombie classifier cannot distinguish a real client running as the PTY root from a surviving launcher whose client exited. Once an adapter-labeled session reaches 30 seconds, a live root with no live descendants is classified as a zombie. The W1b producer is precisely a legitimate live root/leaf: Python itself is the client, and its bind subprocess exits before the producer loop begins. [INFERENCE: historical branch reconstruction] This explains the preserved fresh-reattach refusal without requiring daemon refresh, stale info.json PID attribution, or client death. Exact KIND_SESSIONS reply and a simultaneous descendant census were not retained, so the original classifier inputs were not directly recorded. The source rule and preserved process topology give a specific, falsifiable mechanism, not a claim of a newly executed reproduction. OBSERVED TIMELINE Evidence prefix: .spt/preserved/307/hertz-w1bc/w1b-run1/ 1. daemon-complete.log records direct Python PTY spawn, PID 58496, broker PID 48212, session insertion, and the initial controller attachment. 2. reattach-child-identity-observation.json records PID 58496 with birth 1789524279.7787373, executable C:\Program Files\Python312\python.exe, parent 48212, original generator command line, same isolated home and session ae7c661fcbc1a10f. Observation: 2026-09-16T02:07:16.051489Z. The unchanged birth observed after refusal supports root survival across it, rather than PID reuse. 3. Initial controller attachment at epoch ms 1789524281687: root age 1.908 seconds, inside default grace. 4. events.jsonl: refresh starts 1789524302386, returns exit 0 at 1789524302453. Detach occurs 1789524358466. Fresh rc process starts at birth 1789524360.2485085. 5. rc-2.chunks.jsonl records refusal at 1789524361217: "Endpoint 'w1brig' has a defunct session — its client process tree is gone. Not attaching." Root age calculated from preserved birth: 81.438 seconds. The next output recommends endpoint start to reap it. 6. The update trial begins only at 1789524370503, after this refusal; it cannot cause the earlier refusal. 7. home-w1b/generator-bind.json records successful bind, producer PID 58496, matching session. home-w1b/owlery/w1brig/info.json instead contains transient binder PID 44420 and parent_pid 58496, pid_role binder. That binder PID is not the input to this rc zombie check. Calculated join: .spt/preserved/314/hertz-defunct-leaf-evidence-join.json. EXECUTED PRODUCER TOPOLOGY .spt/preserved/307/hertz-w1bc/w1b-launch1/generator.py:27-36 runs a bounded bind CLI subprocess and waits for its exit. Lines 42-69 then use a Python input thread and main output loop; no persistent client subprocess exists beneath Python. Threads are not process descendants. home-w1b/adapters/evidencerig/manifest.toml launches this Python program directly, not a shell that stays behind a harness child. The preserved broker spawn record independently confirms direct Python. REFUSAL AUTHORITY crates/spt/src/rc.rs:1236-1260 obtains broker sessions, selects the endpoint, and calls session_is_zombie(s.pid, !s.adapter.is_empty(), s.spawned_ms_ago). Query failure or absent row maps to Absent, not ClaimedZombie. crates/spt-daemon/src/broker.rs:6263-6365 builds KIND_SESSIONS from HostedSession: PTY session.process_id(), stored adapter, and elapsed spawn age. It does not substitute the binder PID from info.json. crates/spt-daemon/src/broker.rs:389-424 sets the default grace to 30 seconds and defines the rule: unknown root -> not zombie dead root -> zombie alive root -> adapter_labeled AND past_grace AND no_live_descendants Lines 486-515 obtain process existence and descendants, then apply that rule. crates/spt-store/src/proc.rs:382-417 explicitly excludes the root itself from descendants. crates/spt/src/rc.rs:2178-2208 maps ClaimedZombie to the observed refusal and returns Ok(()). Exit zero therefore does not prove successful attachment. WHY THIS IS NOT MERELY A BAD PYTHON FIXTURE The classifier comment at broker.rs:403-405 asserts that adapter self commands fork a client chain beneath a wrapper "by construction." That assumption is stronger than the manifest contract and the actual spawn behavior. docs/MANIFEST.md:134-161 specifies an opaque role command and psyche_init as the go-live command, not a mandatory persistent descendant. ADR-0041 acknowledges a launcher can exec another image without becoming another process. ADR-0042 requires honest live sessions to remain attachable regardless of perch status, and forbids reader behavior stranding a real live session. A direct native harness or an exec-replaced launcher can have the same leaf topology. Adapter identity alone does not establish which process is the client. The fixture exposes this contract ambiguity; making the fixture grow a dummy child would conceal it. TEST GAP AND IMPACT BOUNDARY crates/spt-daemon/src/broker.rs:10368-10393 tests the Boolean table and labels every adapter/live-root/no-descendants/past-grace combination a dead wrapper. The data model has no way to express a live root-client counterpart. crates/spt/tests/rc_attach_truth.rs:380-439 deliberately creates the valid orphan-wrapper case: cmd ping then pause, or sh sleep then read. It proves that case but not a direct adapter client that legitimately has no descendants. LSP references identify two production consumers of session_is_zombie: rc session truth and the broker AlreadyLive gate at broker.rs:8244. Observed impact here: false fresh-attach refusal. rc itself does not reap. [INFERENCE: source-predicted risk, NOT exercised] A subsequent bringup/start hitting AlreadyLive can pass the same false zombie verdict to reap_zombie_root. Matching process birth protects against killing an unrelated recycled PID, not against wrongly killing the actual still-live root client. The refusal's suggested reap/start remedy is therefore unsafe for this classification error. This diagnosis does not explain away #307's held-stream/refresh ACK stall. Fresh reattach is an additional reader gate; the W2 refresh regression does not depend on executing this fresh-reattach gate. REMEDY BOUNDARY — PROPOSAL, NOT IMPLEMENTATION Make client identity/topology an explicit authority rather than inferring it from nonempty adapter name. Distinguish a root that is the actual client from an owned launcher whose separately identified client has exited. Keep the shared lifecycle classifier and preserve existing orphan-wrapper detection; do not unconditionally accept every live root. Narrow implementation design must determine where launch/client identity is recorded and how its lifetime is observed. A delay increase, Python executable exception, synthetic descendant, blanking adapter identity, or skipping the honest-attach gate is not a fix. Required discriminators for that lane: - Direct adapter client, alive and childless beyond grace: attach remains allowed; duplicate bringup must not kill/replace it. - Actual adapter wrapper after its identified client exits: zombie refusal and identity-safe reap still work. - Repeat the leaf case without refresh: it should expose this classifier defect independently of #307. - Preserve dead-root and PID-reuse safety cases. No tests were changed or run for this read-only RCA. Existing preserved execution was joined and measured; no fresh behavioral verification is claimed. Before implementation, a bounded owned fresh-home reproduction with raw session fields and simultaneous process identity/descendant capture would close the remaining historical-input uncertainty. DISPOSITION Route #314 to core lifecycle truth/adapter launch-contract ownership, not the #307 refresh transport remedy. Keep #302 worker-starvation investigation separate. No resident ENLYZEAM mutation or permission escalation is justified by this result.