# 0047 — Daemon lifecycle: custody identity, operator-stop authority, idempotent attach replay

Date: 2026-07-22
Status: accepted
Amends: ADR-0044 (controller lease ladder — equal-generation rung), REQ-DAEMON-3 (implicit daemon ensure — now yields to a standing operator stop)

<!-- REQ set minted at DAEMON-LIFECYCLE triage 2026-07-22 (doyle): W1 = REQ-RESUME-CUSTODY-IDENTITY, REQ-ENSURE-DAEMON-STOP-INHIBIT, REQ-ATTACH-IDEMPOTENT-REPLAY, REQ-HAZARD-RESUME-CUSTODY-ABA, REQ-HAZARD-STOP-RESPAWN-CONVOY; W2 (inactive) = REQ-STREAM-LIFETIME-CLASS, REQ-STAMP-CONVERGENCE-ORDER. -->

## Context

Three v0.39.4 field regressions were RCA'd by hertz and code-verified the same day
(2026-07-22), all in the daemon's lifecycle layer, all shipped-for-months latent
defects that the field finally selected:

1. **False-ONLINE after core restart.** A live agent read as online while its
   session was not running. Root: `resume.pid` custody is a **bare PID** with no
   identity binding — `read_resume_pid(..).is_some_and(is_process_alive)` at both
   the restart gate and the liveness-reconcile DEFER. A dead spawn's pid, recycled
   by the OS onto an unrelated process (field proof: `resume.pid=29456` resolved
   to a random `cmd.exe`), reads as "a resume is in flight" forever: the reconcile
   defers every tick, the row stays online-authoritative, and no reader ever
   repairs it. The ABA problem, in process custody.

2. **`daemon stop --force` non-terminal (respawn convoy).** Stopping the daemon
   spawns 5–10 ephemeral windows and the daemon is running again; it takes several
   force-stops to stay down. Root: every `spt api` invocation runs an
   **unconditional `ensure_daemon()`** (REQ-DAEMON-3's "reliable anchor"), and a
   box hosting live adapter sessions generates a steady stream of hook-driven api
   calls — so the stop is a race the operator loses. The rc-side twins of this bug
   were fixed earlier (rc.rs); the api anchor was left armed.

3. **`spt update` freezes every attached rc PTY.** Pinned by the v0.40.0 OBS
   breadcrumbs on an authorized same-seam `daemon refresh` (falsifying the
   presumed gen+1 mechanism): during post-cycle dispatcher replay the broker
   processed the SAME conn / SAME identity / SAME generation subscribe twice
   15 ms apart. The equal-generation rung of the ADR-0044 ladder classifies that
   as "same lease, silent re-take" — correct — but re-take is implemented as
   `become_controller`, which unconditionally drops the prior seat (its writer
   exits channel-closed). Designed for a dead seat after a dispatcher restart, it
   also fires against the same LIVE conn: the lease kills its own writer and the
   viewer freezes until a manual detach + re-attach.

A fourth field bug (spt-hosted PTY corruption at fixed geometry) is still being
RCA'd (hertz, on enlyzeam, isolated-rig non-repro already banked as the
isolation-sensitivity discriminator). **Its fix rolls into this milestone as a
later wave when the RCA lands** (operator ruling 2026-07-22); nothing is minted
for it yet.

## Decisions

### 1. Process custody is an identity, never a bare PID

<!-- [doc->REQ-RESUME-CUSTODY-IDENTITY] -->

A custody record that can gate lifecycle decisions (`resume.pid` first; the shape
generalizes) stores an **identity pair — pid + process creation time** — written
atomically when the spawn is minted. Every consumer that today asks
`is_process_alive(pid)` asks instead "does a process with THIS pid AND THIS
creation time exist": a recycled pid fails the pair test and reads **NOT OURS**.

- **Mismatch is self-healing, not an error:** a custody record whose pair no
  longer matches a live process is DELETED by the reader that discovers it, and
  the gated decision proceeds as if no custody existed (the spawn is dead; saying
  so is the repair). No operator step.
- **Atomic clear on the happy paths:** successful bind and spawn-reap both clear
  the custody record in the same mutation that records their own outcome — the
  record never outlives the question it answers.
- Windows: creation time comes from the process snapshot, not from a retained
  handle (KNOWN-HAZARDS 7.50 — the asker owning a handle is exactly the unsound
  case). A platform with no snapshot yields "unproven", which defers ONE tick —
  it never manufactures either verdict (7.49/7.50 precedent).

**Rejected:** a spawn-generation counter file next to the pid (a second file can
skew against the first; the pair travels in one record); process-name matching
(names collide by design — every spawn is `spt`).

### 2. An operator stop outranks every implicit ensure

<!-- [doc->REQ-ENSURE-DAEMON-STOP-INHIBIT] -->

`spt daemon stop` records a durable, machine-scoped **stop inhibit** BEFORE it
begins teardown. `ensure_daemon()` / `ensure_running()` — every implicit
autostart path — consults the inhibit and, when present, **declines to spawn**,
printing one honest line (`daemon stopped by operator — spt daemon start to
resume`) instead of silently resurrecting the daemon. REQ-DAEMON-3's anchor
contract is hereby amended: the anchor holds *except against a standing operator
stop* — an explicit stop is operator intent, and no convenience path outranks it.

- **Cleared by intent verbs only:** explicit `spt daemon start`, and the update
  paths that restart the daemon by design (`update apply --finish`). **No TTL** —
  a timeout would re-introduce the surprise respawn this decision exists to kill.
  A stale inhibit fails safe (daemon stays down, every api caller says why) and
  costs one explicit `daemon start`.
- **Spawn serialization:** implicit autostart takes a machine-wide lock around
  probe-and-spawn, so N concurrent api calls can never launch N daemons (the
  convoy's second half — the 5–10 ephemeral windows were racing spawners).
- `daemon stop` clears its own inhibit on teardown FAILURE exit paths only when
  it never began teardown; a partially-torn daemon with the inhibit standing is
  the correct resting state (down + honest refusals) rather than a half-up race.

**Rejected:** TTL-based inhibit (surprise respawn, just later); process-scoped
inhibit (the convoy IS cross-process); gating only `--force` (the non-force stop
loses the same race).

### 3. The equal-generation rung is idempotent for the same connection

<!-- [doc->REQ-ATTACH-IDEMPOTENT-REPLAY] -->

ADR-0044's ladder gains a discriminator INSIDE the equal-generation rung: when a
Control/Take subscribe carries the **same identity, same nonzero generation, AND
the same connection as the incumbent seat**, the broker treats it as an
**idempotent replay** of the establish it already honored — the seat and its
writer are PRESERVED, no `controller-replaced` fires, no second initial batch is
synthesized. The subscribe decision breadcrumb answers `decision=idempotent`
(additive vocabulary) so field captures distinguish reuse from replacement
without correlating writer-exit lines.

- Equal generation on a **different** connection keeps today's silent seat swap —
  that is the designed dispatcher-restart successor (ADR-0038 fix-6) where the
  old seat is dead and the lease is being re-served on a fresh carrier.
- Strictly newer generation keeps the loud, fenced supersession; strictly older
  keeps the busy refusal. The ladder's ordering rules do not change — this is a
  new leaf inside one rung, keyed `(endpoint/session, by, conn, gen, from_seq)`.
- **Key refinement (build-flagged, doyle-ruled 2026-07-22): `from_seq` is the
  FIFTH key element, and it is load-bearing.** Core itself re-subscribes on the
  SAME conn with the SAME generation for a legitimate reason: the controller
  gap resume (`read_controller_event_resuming`,
  REQ-HAZARD-CONTROLLER-GAP-RESUME) re-fetches dropped frames from its frozen
  `delivered_through` floor. That request is not a replay of the establish — it
  asks for different bytes — and keying on `(by, conn, gen)` alone would
  suppress it, strand the gap, and convert a recoverable gap into a false
  `ControllerIrrecoverablyBehind` truncation. A replayed establish carries the
  `from_seq` it was first honored with (`ControllerSink.establish_from_seq`); a
  re-fetch carries the floor it has since reached. **Accepted residual, stated
  honestly:** a gap with ZERO frames delivered since establish has
  `floor == establish_from_seq` and is suppressed — its consequence is a
  SURFACED truncation (never a silent lie), it needs a flood landing before any
  delivery advances the floor (not constructible in the rig), and a dedicated
  unit guards the collision cell.
- Why the dispatcher served one held attach twice within 15 ms is a **banked
  open question** for this wave's build (it may have siblings); broker
  idempotence closes the symptom for every origin and does not wait on it.

### 4. Wave map (build sequencing, not new semantics)

- **W1 (dispatched now):** decisions 1–3 — the three field regressions with
  accepted RCAs. Regression legs ride each fix (see the REQ gates).
- **W2 (registered inactive):** stream lifetime/lease classes — rc attach
  streams become ConnectionBound (FIN on opener-conn EOF) while inter-brain
  durable streams stay durable (the C2 raw-viewport leak, ADR-0038 territory) —
  and the C3 poll-vs-reap stamp-convergence race, which W1's gate must first
  re-verify still exists post-REGISTRY-LIFECYCLE. Design details are a W2 triage
  refresh against the code as it then stands, not this ADR.
- **W3 (unminted):** the PTY-corruption fix, shaped by hertz's pending RCA.

## Consequences

The daemon's lifecycle claims become custody-honest (a recycled pid can no
longer impersonate a live spawn), operator-honest (stop means stopped until the
operator says otherwise), and lease-honest (a lease can no longer displace
itself). The stop-inhibit is a deliberate behavior change to REQ-DAEMON-3's
anchor: harness hooks on a stopped box now print a refusal instead of healing
the daemon — that is the point, and the message names the remedy.

## Amendment 1 (2026-07-22) — W3 shaped: every resize entry rides ONE presentation barrier

<!-- [doc->REQ-ATTACH-RESIZE-REPAINT] -->

**The bug-3 RCA landed** (hertz, ENLYZEAM production byte capture, debug
f31849c, same day as this ADR): the field PTY corruption is an **attach/resize
presentation race**, not a parser or width defect. The controller attach
delivers the synthesized repaint at the session's OLD hosted geometry (80x24),
then rc's initial resize (the attach-stream verb `send_attach_resize`, ridden
once per viewport establish) lands the PTY + ScreenGrid on the client's real
geometry (131x60) — and **no resize-synchronized repaint follows**. Every
subsequent differential frame assumes the resized, reflowed model while the
client's physical screen still holds pre-resize cells; incremental keystroke
paints expose them as the `/c`-scrap misalignment. Proof both directions:
replaying old-geometry repaint + post-resize frames in an independent emulator
at 131x60 reproduces the corruption; replaying from the first post-resize full
frame is clean; a fresh viewer (served a current-geometry repaint) is clean.
Transport exonerated (raw-PTY and broker-frame taps byte-identical).

**Why three iterations missed it:** v0.39.3/v0.39.4 built the geometry-epoch +
presentation-barrier machinery for the IPC `ResizeReq` path — and that path is
sound: `commit_resize` with an armed transition pushes the authoritative
repaint at the landed geometry. But the codebase has **two resize entries**,
and the attach-stream resize verb never arms the barrier — it reaches the
no-transition branch whose comment claims "no suppression, so no sync frame
owed". The field capture falsifies that claim: **a geometry change invalidates
the client's painted cells by itself, bytes-in-flight or none.** It also
explains the isolated-rig non-repro (a fresh session has no stale-geometry
content to expose) — isolation sensitivity resolved, not mysterious.

**Decision:** geometry change is ONE transaction with ONE exit shape,
regardless of entry: resize → grid lands → **authoritative full repaint at the
new geometry to every attached sink** → only then subsequent output. The
attach-stream resize verb rides the SAME barrier machinery as `ResizeReq`
(arm → settle → issue → commit/abort); the no-transition branch of
`commit_resize` survives only for genuinely sink-less sessions, and its
"nothing owed" claim is retired from any path with an attached sink. Ordering
at attach specifically: a controller attach whose viewport geometry differs
from the hosted geometry must not paint the old-geometry repaint as its final
word — the initial resize's committed repaint supersedes it.

**Oracle (binding, hertz's contract):** the regression replays the captured
shape — 80x24 hosted session with content → 131x60 controller attach → the
menu/keystroke differential pattern — into an INDEPENDENT VT emulator and
asserts final cells/colors. A ScreenGrid-only oracle shares the model under
test and MUST NOT be the sole authority (the REQ-SCREENGRID-WIDTH precedent).

W3 is hereby shaped; REQ-ATTACH-RESIZE-REPAINT is minted inactive and
activates when W3 dispatches (sequenced behind W1 — same broker.rs seam, same
builder, no parallel edits to one file).

### Amendment 1 addendum (2026-07-22, same day) — scope widened: resize also corrupts the hosted terminal's INPUT discipline

<!-- [doc->REQ-RESIZE-INPUT-MODE-INTEGRITY] -->

A second production capture (operator resized the EXISTING rc window live;
hertz froze the taps) amends the RCA's scope two ways:

1. **Not attach-limited.** A mid-session resize of an established viewport
   reproduces immediately — the presentation-ordering defect above applies to
   any geometry change, which the "one transaction, any entry" decision already
   covers.
2. **A second, direct mechanism:** after the resize, the RAW ConPTY drain
   emits the operator's keystrokes as isolated OUTPUT records — literal
   one-byte `c`/`o`/`n`/`f`/`i` frames interleaved with the TUI's
   cursor-addressed diffs (raw and broker taps byte-identical, so this is the
   hosted terminal echoing, not transport or parser). The screenshot's
   `/c/config` line is exactly this unintended echo colliding with the menu's
   own `/config` cell paint. The hosted TUI runs raw/no-echo; something across
   the resize path re-enables console echo on the nested ConPTY — a path
   non-spt sessions do not traverse. spt-core never touches console input
   modes anywhere in-tree, so the alteration happens inside the
   portable_pty/ConPTY resize seam and is currently UNINSTRUMENTED.

**Decision (investigation-shaped, not root-guessed):** W3 instruments Windows
console input mode across the surface resize (`spt-term` pty resize) and
immediately before `write_input`, verifies `ENABLE_ECHO_INPUT` /
`ENABLE_LINE_INPUT` remain disabled across a resize, and restores/asserts the
mode if the seam proves to reset it. The echo mechanism is the likely `/c`
producer; the ordering fix above remains necessary on its own evidence. The
regression gains hertz's second leg: active hosted TUI, resize 80x24→131x60,
type `/config` — raw PTY output must contain TUI frames and ZERO standalone
echoed input bytes, and independent-VT final cells must equal ScreenGrid.

## Amendment 2 (2026-07-22) — W3 bisect: mechanism 1's root is the rc CLIENT's console mode, not the broker seam

<!-- [doc->REQ-RC-NEWLINE-PRESENTATION-TRUTH] -->
<!-- [doc->REQ-ATTACH-RESIZE-REPAINT] -->

Amendment 1 named a code root for the field `/c/config` corruption ("the
attach-stream resize never arms the barrier"). **That root is RETRACTED.** The
W3 bisect falsified it three independent ways and then pinned the real one.
Absence of a root in the seam we were auditing is a finding, not the absence of
one — and the mechanism the bisect exposed turned out to BE the answer.

### What the bisect proved

Three isolated probes, each removing one thing:

1. **Our seam alone.** `ScreenGrid::new(24,80)` → advance(PRE window) →
   `resize(60,131)` → advance(POST window) → `render_repaint()`, rendered in
   `avt` against the authority built from the same file bytes: **0 divergent
   rows.** The grid, the resize and the repaint synthesis are byte-clean over
   the field capture.
2. **The vehicle alone.** `capture-player` under a real ConPTY at a FIXED
   60x131 — no broker, no grid, no attach, **no resize at all** — reproduced
   the ENTIRE observed divergence: one row, row 52, the exact field string
   (`"/c/config"` where the capture says `"  /config"`). 100% of the red, with
   zero spt code in the path.
3. **The mechanism, minimally.** `ESC[2J ESC[5;3H "XX" <LF> "AB"` renders row 6
   as `"    AB"` in a spec VT (LF = INDEX: row down, **column preserved**) and
   as `"AB"` through a Windows console (LF = CR+LF: **column reset**).

Capture record `t=1784714152501` is `CUP 51;3` + `EL` + SGR + a bare `0x0a`
with no `0x0d` + `"/cd…"` — a bare LF used as an index at column 3. Under the
console's translation the row's paint starts at column 1 instead of 3; the
later absolutely-addressed writes (`ESC[52;3H"/copy"`, then `ESC[52;4H"config"`)
repaint from column 3 onward and never erase columns 1–2, so the `"/c"` residue
survives to the final screen. That is the field string, derived.

### The root (statically provable, no box required)

`rc.rs` `with_vt_output` composes the operator console's OUTPUT mode as
`prior | ENABLE_VIRTUAL_TERMINAL_PROCESSING | ENABLE_PROCESSED_OUTPUT`, and
`DISABLE_NEWLINE_AUTO_RETURN` occurs **nowhere in the tree** (zero grep hits).
With processed output on and DNAR absent, the operator's console translates
every relayed bare LF into CR+LF. **The relay is byte-clean end to end and then
our own client hands those bytes to a console configured to mangle them.** This
is a presentation-truth defect in rc, independent of any resize.

Every prior observation collapses onto this one mechanism:

- the field scraps appear at a **fixed window size** — bare-LF debris needs no
  resize, which is the no-resize interior-debris discriminator we could never
  place;
- the **fresh-viewer-clean** contrast — a fresh attach is served the synthesized
  repaint, which is CUP-absolute and contains no bare LFs, while the live
  differential stream carries them;
- **both isolated rigs read NOT-REPRODUCED** — they compared bytes, or rendered
  through spec VTs (`pyte`, `avt`), and the bytes ARE clean; nobody had rendered
  through a really-configured Windows console client;
- **the W3 rig's red** — its replay ConPTY had the same missing-DNAR semantics
  as the field rc console, so the rig was an accidental faithful model of the
  CLIENT while being consulted about the BROKER.

The Unix half of this symmetry already exists: rc's raw mode clears
`OPOST`/`ONLCR`. This is the Windows half, missing since #12.

**Decision:** DNAR joins the `with_vt_output` composition wherever processed/VT
output is enabled; prior-mode capture and restore-on-drop are unchanged. A bare
LF in a relayed stream is an INDEX, never a newline. Tracked as
`REQ-RC-NEWLINE-PRESENTATION-TRUTH` / KNOWN-HAZARDS 7.56.

### What survives from Amendment 1

The **decision** stands untouched: geometry change is one transaction, one exit
shape, any entry. It was ratified on its own merits and this amendment does not
disturb it. What is retracted is only the claim that it was **bug 3's root**.
The attach/resize seam is exonerated for mechanism 1; the static trace
(`send_attach_resize` → serve_attach Resize arm → `brain.resize` → KIND_RESIZE →
`dispatch_resize` → `begin_resize_serialized`), the synthetic sibling's pass and
the broker's own dynamic trace (attach → `RESIZE_DISPATCH` →
`SYNC_PUSH{controller=true}`, no `RESIZE_SYNC_DROP`) are all consistent with a
seam that does its job.

### Oracle clauses (binding for this rig class)

Ratified from the W3 build, each paid for by a real blinding:

1. **Both sides independent.** Authority and candidate are each `avt`, never
   ScreenGrid against itself (REQ-SCREENGRID-WIDTH precedent).
2. **Geometry-sensitive without being reflow-sensitive.** No `avt.resize`
   anywhere; authority and candidate are each built at the target geometry from
   absolutely-addressed bytes.
3. **Capability-probed at EVERY shape revision, with its own precondition
   asserted.** A permanent leg seeds the defect class and asserts the comparison
   SEES it. A narrowed fixture re-blinds an oracle exactly like a redesigned one,
   so the probe is re-run per revision, not per design.
4. **Pre-state soundness self-check as a required leg.** Feeding
   narrow-geometry bytes into a wide authority is only sound if those bytes are
   absolutely addressed rather than relying on auto-wrap; the pre window is
   rendered at both widths and required to agree over the shared region with
   nothing spilling past the narrow margin. Raw-bytes-rendered-at-the-wrong-width
   is the reflow trap in a third costume.
5. **Byte integrity pinned, per record.** The fixture is pinned against
   line-ending rewriting (`*.log -text`) and by a sha256 table; the player and
   the reader both REFUSE a record whose hex payload disagrees with its declared
   length. A truncated evidence file replaying as a short screen looks exactly
   like the corruption under test.
6. **Clause-1 reading (captured evidence vs executed machinery).** The ban is on
   executing defect-carrying machinery to produce an authority, NOT on captured
   evidence that our renderer once emitted. Field-captured bytes read from a file
   are evidence; bytes regenerated by running the model under test are not.

### The vehicle-fidelity clause (new, and general)

Any rig placing a vehicle that is **not our code** between the fixture and the
code under test (a ConPTY, a shell, a terminal multiplexer) must prove that
vehicle transparent before its verdict counts.

**Per CELL, not per byte.** ConPTY re-renders into its own diff stream
(`ESC[?9001h`, `ESC[1C` for runs of spaces, its own `ESC[2J`/`ESC[m`/`ESC[H`
preamble); the measured POST window came back 14929 bytes against the file's
14598 and will never be byte-identical. Byte equality is unsatisfiable by
construction and would disqualify every ConPTY rig forever, including 7.55's. The
satisfiable and sufficient contract is: **the vehicle's emitted screen, rendered,
equals the file's bytes rendered, cell for cell.** On failure the rig is
DISQUALIFIED, not debugged.

A rig that cannot meet the clause has one honest alternative: **declare itself a
client-model rig in its header** and be read as such.

### The rig is re-attributed, not retired

The W3 captured-bytes rig is not withdrawn. Unmodified — vehicle console without
DNAR — it models the FIELD CLIENT and is the RED-FIRST proof for the rc fix. With
DNAR set on the vehicle console it models the FIXED client and must go green.
**That pair is the regression**, and both halves are measured: red on the exact
field string, then 3/3 green with the capability probe still detecting its seeded
mismatch, so the green is not vacuous.

One limit, stated: the rig pair proves the MECHANISM, not the LOCATION. Its
console sits at the child's write into the pseudoconsole, whereas the field
console sits at the operator's display; the LF semantics are identical and the
resulting string is identical, but the rig alone does not prove the field chain
ends where we say it does. The minimal LF probe through an **rc-mode** console is
what pins the location, and hertz's field confirm (fixed rc showing clean
`/config` on the same session where stock rc shows `/c/config`) is what closes
attribution. That confirm needs no debug channel, no channel flip and no quiesce
window — which retires, for mechanism 1 only, the production debug-channel grant
as a blocker.

### Console-mode seam: the class

Three manifestations in one day — relay presentation LF (this amendment), the
replay vehicle LF (the bisect instrument), and 7.55's input echo across resize —
are one uninstrumented seam. The 7.55 instrumentation contract therefore extends
to record `DISABLE_NEWLINE_AUTO_RETURN` alongside `ENABLE_ECHO_INPUT` /
`ENABLE_LINE_INPUT`, sampled **before the resize, after the resize, and
immediately before `write_input`** — a one-shot set at startup says nothing about
what the seam does to the bit later. Measured so far: setting DNAR on a replay
console makes that vehicle cell-transparent. NOT yet measured: whether a resize
mutates it. That is the open question, and it is 7.55's.

### REQ disposition

`REQ-ATTACH-RESIZE-REPAINT` **retires its defect claim and rescopes** to what
was actually built and is worth keeping: the captured-shape regression oracle,
the ENLYZEAM fixture and its integrity pins, and the probe ladder (vehicle
transparency + minimal LF asymmetry + pre-state self-check + capability probe).
Its `impl` stage is dropped — there is no broker-side implementation owed —
leaving `unit` (the record-integrity refusals and the vehicle-transparency
predicate) and `int` (the rig pair). The defect stages move to
`REQ-RC-NEWLINE-PRESENTATION-TRUTH`, which activates on the W3 branch with the
fix. `REQ-RESIZE-INPUT-MODE-INTEGRITY` (7.55) is untouched by all of this: its
capture is a live field capture with no replay vehicle in its path, so its
evidence is not contaminated by today's finding.
