# ADR-0041: Endpoint lifecycle truth — online classification, terminal normalization, stamp convergence, truthful create

- Status: accepted (doyle triage 2026-07-17, REGISTRY-LIFECYCLE milestone)
- Source: three hertz reports (2026-07-16, doyle seam-verified same day — emphasys triple,
  rest-normalize, spawn-fresh) + the operator-field deployah stop/run wedge. KNOWN-HAZARDS
  7.45. Kin: ADR-0027 (unbound endpoint state), ADR-0033 (wake-resume via reconcile
  intent), KH 7.27/7.29.

## Context

Four proven defect families share one root shape: **endpoint lifecycle state is written by
multiple non-converging paths, and several of them stamp optimistic state that no
authority ever verifies or normalizes.**

1. **Hybrid false-ONLINE (emphasys C1).** `cmd_listen` stamps `status=online` from manifest
   `psyche_init` alone (live_capable) — no persisted-state or hosting-authority check —
   minting `ready_agent`/`controllable=false` hybrid rows born online-authoritative. The
   livehost reconcile skips `state != live_agent` rows AND gates cleanup on
   `controllable=true`, so a dead-PID hybrid survives every restart; `is_perch_alive`
   trusts `status=online` and skips dead-PID rejection — every reader faithfully
   resurrects the lie.
2. **Zombie WAKE_RESUME (rest-normalize).** `rest_state` is intent, distinct from liveness.
   Definitive session death (broker session loss, liveness reap, `cmd_stop`) stamps
   `status=offline` ONLY — never touches `rest_state`/`dormant_since_ms`. A dead session
   with preserved `Active` intent = an outstanding wake order: reconcile offlines it, a
   later pass RESUMES it from the ledger, repeatedly, across generations (perri field:
   repeated `WAKE_RESUME` loops).
3. **Poll-vs-reap relatch (emphasys C3).** `KIND_SESSIONS` snapshots controller truth under
   lock but writes stamps after unlock; an exit-waiter reap in between lets the stale poll
   relatch `controlled=true` on a session that just died.
4. **Untruthful create + contradictory liveness (spawn-fresh + cycle wedge).**
   `endpoint run --create` promises a fresh session but dispatch discards `create`; the
   broker dedups every labeled SpawnReq into `Spawned(existing)` with no disposition — the
   CLI prints success for a spawn that never happened. Meanwhile the ALREADY_LIVE dup-guard
   and the shutdown state machine consult DIFFERENT liveness sources and contradict each
   other on the same zombie (deployah wedge: run says ALREADY_LIVE, list says OFFLINE,
   shutdown says NO_EDGE).

## Decision

<!-- [doc->REQ-ENDPOINT-ONLINE-TRUTH] -->
1. **Online is earned, not declared.** A creator may stamp `status=online` only from actual
   persisted state + hosting authority — never from manifest capability alone. Legacy
   hybrid rows self-heal at reconcile, but only after a SUCCESSFUL broker query: a broker
   failure is never interpreted as an empty session set (no mass-offline on a hiccup).
2. **Control cleanup splits from offline classification.** Reconcile clears
   `controlled`/`driven_by`/`viewer_count` for EVERY endpoint absent from session truth —
   regardless of state or controllability — while offline classification keeps its narrow
   gate (live_agent + controllable). A row's classification quirk can never shield stale
   control stamps.
<!-- [doc->REQ-REST-TERMINAL-NORMALIZE] -->
3. **Definitive death normalizes terminally and atomically.** One store-level mutation —
   `status=offline` + `rest_state=suspended` + clear `dormant_since_ms` in the SAME
   info.json write — invoked at every definitive hosted-session-loss point (authoritative
   broker session loss, liveness reap, `cmd_stop`). Explicitly NOT
   `daemon_rest_event(Suspend)` (no edge when effective state is already Suspended — the
   raw Active intent survives), and NOT a reader-side blanket offline⇒suspended (that
   destroys explicit-Wake semantics: wake writes intent first, reconcile consumes it).
   Graceful shutdown keeps its echo-before-teardown; this is terminal normalization for
   non-graceful death.
<!-- [doc->REQ-CONTROL-STAMP-CONVERGENCE] -->
4. **Control stamps converge, validated.** Stamp writes carry session/generation validation
   (or serialize per-endpoint stamp transitions) so a pre-reap snapshot can never overwrite
   a post-reap truth. No fs I/O under global locks (KH 7.12). Every no-session path clears
   all three control fields.
<!-- [doc->REQ-SPAWN-FRESH-TRUTHFUL] -->
5. **Create is truthful at the broker, atomically.** Spawn policies split:
   Fresh/RejectExisting vs ResumeOrReuse. New `KIND_SPAWN_FRESH` wire verb with a TYPED
   conflict outcome (a distinct kind fails loud on an N-1 broker; a serde-defaulted field
   would be silently ignored — wire discipline), sharing the single-flight/PTY spawn body.
   Fresh at AlreadyLive/lost-race returns the conflict, never `Spawned(existing)`. CLI:
   exit 1 + stable `ENDPOINT_CREATE_CONFLICT:<id>:…no session was created`, no attach, no
   state mutation, never kills the live session. Omitted `--resume` keeps the documented
   create-default (fresh-and-conflict, same as explicit `--create`); ensure/attach would be
   a NEW documented mode, never a silent overload. Preflight stays UX-only (cannot close
   TOCTOU).
<!-- [doc->REQ-ENDPOINT-CYCLE-HONEST] -->
6. **One liveness authority for cycle verbs.** The ALREADY_LIVE dup-guard liveness-probes
   the claimed session's client tree before refusing (dead tree ⇒ reap + respawn
   honestly); the shutdown state machine consults the same source, so "is it live" has one
   answer (no ALREADY_LIVE/OFFLINE/NO_EDGE three-way contradiction).

<!-- [doc->REQ-BROKER-ZOMBIE-IDENTITY] -->
   **Zombie-kill identity guard (releases#285).** Liveness is not permission to
   kill a remembered PID. The broker records the root's OS creation time just
   after PTY spawn, before starting its exit waiter. The child is still owned:
   on Windows its handle is retained; on Unix it has not been reaped, so its
   number cannot yet be recycled. The expected identity is never refreshed at
   reap time. Start time is preferred over image because a launcher can `exec`
   another image without becoming a different process.

   At the zombie kill boundary, a fresh owned-process death probe permits
   stale-row cleanup for a dead root **without any PID/tree kill**. A fresh
   start-time query that differs from the captured stamp also proves the
   remembered child is gone: `SPAWN_ZOMBIE_REAP_RECYCLED` records both births,
   skips signaling the replacement process, and proceeds through the existing
   bounded de-table and stamp-clear loop so the next spawn can succeed.
   Only a matching live birth permits tree-kill.
   Unknown PID or missing/unreadable identity emits
   `SPAWN_ZOMBIE_REAP_DECLINE` and fails the spawn request **before** the forced
   de-table loop. There is no fallback `session.kill`, teardown dispatch, or
   session-resource drop on refusal; the old row remains for its normal exit
   waiter. This trades automatic recovery of an **unverifiable** live-number
   row for safety, but never strands a row whose recorded birth is proven gone.
   Fresh-dead cleanup retains the existing snapshot-less Unix liveness fallback.

   **Limits:** this is a checked numeric tree-kill, not an atomic handle/pidfd
   tree termination. A process can exit and its PID can be reused after the
   identity query and before a signal; descendant enumeration/kill retains the
   same race. Linux start times have clock-tick resolution, so same-tick reuse
   is indistinguishable; Windows uses creation FILETIME. This scoped guard does
   not change generic `kill_pid_tree` or other kill sites.

## Consequences

- A dead endpoint converges to offline+suspended+uncontrolled within one reconcile, from
  every death path, and stays there until an explicit wake or run — no resurrection loops,
  no zombie control latches, no immortal hybrid rows.
- `--create` becomes a contract: success ⇒ a fresh session exists; conflict ⇒ typed, loud,
  side-effect-free. Existing no-dup safety tests remain valid (they codify the dedup body
  this shares).
- New wire surface: `KIND_SPAWN_FRESH` (additive verb). N-1 broker answers unknown-kind ⇒
  CLI reports the loud IPC failure, never a fake Created.
- Explicit-Wake semantics are preserved by construction (intent normalization happens only
  on definitive death observations, never speculatively in readers).

## Requirements

`REQ-ENDPOINT-ONLINE-TRUTH`, `REQ-REST-TERMINAL-NORMALIZE`, `REQ-CONTROL-STAMP-CONVERGENCE`,
`REQ-STREAM-LEASE-CLASSES` (mechanism in ADR-0040 decision 6),
`REQ-HAZARD-ENDPOINT-LIFECYCLE` (KH 7.45); W3: `REQ-SPAWN-FRESH-TRUTHFUL`,
`REQ-ENDPOINT-CYCLE-HONEST`.

<!-- [doc->REQ-HOSTING-AUTHORITY-CONTROLLABLE] -->
## Amendment (RC-RENDER-TRUTH v0.38.1 leg 3, doyle ruling 2026-07-19): one hosting authority — the online-earn authority splits by hosting topology

Persisted `state` is the durable endpoint TYPE (REQ-EP-6 open type system;
`establish_perch`'s prior-type preserve is INTENTIONAL and stays). The source-definitive
BROKER-PTY hosting authority is `controllable == Some(true)`, NOT `state`.

**Field root (a coverage gap, not a new hazard family).** An spt-hosted bind OVER a prior
`ready_agent` perch preserves `state = ready_agent` while stamping `controllable = true` +
`online`. Pre-amendment this row was IMMORTAL: `restart_resume_gate` skipped
`state != live_agent` so the orphan never resumed, and the reconcile dead-pid hybrid heal
was scoped `controllable == Some(false)` so `controllable == true` escaped terminal-offline
— a dead-PID `ready_agent` hybrid projected Active forever (rc's no-session refusal was
truthful). The online-ness was never the bug; the UNHEALED-ness was.

**Resolution.** Hosting authority = `controllable`, applied consistently at every seam so
the three seams agree on what "hosted" means:

1. **`restart_resume_gate` keys on `online + controllable + belts`, no `state` arg.**
   endpoint_survival tables BOTH `live_agent` and `ready_agent` broker-owned rows — both
   RESUME their PTY at daemon start.
2. **The reconcile routes only `non-live_agent && controllable != Some(true)` through the
   PID-model hybrid heal.** A `controllable == Some(true)` row (any state) falls through to
   BROKER-SESSION truth: orphan with ledger/adapter material ⇒ Resume; no session + dead
   custody ⇒ terminal-offline via the atomic normalize. The immortal hybrid is now fully
   managed.
3. **`cmd_bind` earns `online` from the PERSISTED record it just wrote, on
   `controllable == Some(true)` — never the requested arg.** This makes the online signal
   `==` `restart_resume_gate`'s resume predicate (online+controllable) by construction, so
   there is never an online-but-unresumable perch.

**The online-earn AUTHORITY SPLITS by hosting topology** (both obey the SAME discipline —
earn from persisted truth, never the requested arg — differing only in which persisted
datum is the authority):

- **Messaging / `listen`** (no broker PTY) earns `online` on persisted `state == live_agent`
  (ADR-0041 decision 1, `listen_online_gate`, UNCHANGED). This still refuses the
  capability-only phantom it closed — a `controllable == false` row born online off manifest
  capability alone.
- **Broker-PTY / `bind`** earns `online` on persisted `controllable == Some(true)`. This is
  REAL hosted state (the daemon spawned a live session into a broker-owned PTY), so it does
  NOT reopen the capability-only-phantom hole — that hole was `controllable == false`.

**Psyche hosting stays separately state-gated** (`live_agent` only, at the reconcile
pulse-host): a `ready_agent`-preserved bind is online + controllable + PTY-resumable + NO
Psyche — all consistent. `psyche_init` is NOT part of the online predicate; a `live_agent`
broker PTY with no psyche is still a live resumable session (online is truthful).

Requirement: `REQ-HOSTING-AUTHORITY-CONTROLLABLE`.
