# ADR-0040: Bounded stream/seat lifecycle — one-way terminal semantics, server-side eligibility, transactional feed apply, cross-family seat release, lease classes

- Status: accepted (doyle triage 2026-07-17, REGISTRY-LIFECYCLE milestone)
- Completes: ADR-0038 (broker stream lifecycle truth) — this ADR closes the lifecycle gaps
  ADR-0038's retire machinery deliberately left open, after the field proved they compound.
- Source: hertz post-close v0.36 field RCA
  (`.claude/reports/2026-07-17-registry-stall-rca/README.md`, doyle-verified 2026-07-17) +
  the emphasys C2 viewport-leak report (2026-07-16). KNOWN-HAZARDS 7.44.

## Context

ADR-0038 gave broker-held net streams truthful *dispatch* lifecycle: finished Attach rows
retire, opener classification is restart-durable, claims retry bounded. Retirement was
defined as ELIGIBILITY-visibility only — the physical `StreamEntry` (send half, subscriber
seats, writer threads) deliberately survives until the owning QUIC connection closes, so an
in-flight reply always flushes.

The field (hertz, live v0.36 box) proved four compounding consequences on LONG-LIVED
connections, where the conn-close sweep never comes:

1. **Sender history never retires.** The registry pump opens one fresh stream per feed
   (~30s cadence) on the persistent pump conn and FINs it — but nothing retires the row:
   the dispatcher is the sole `retire_stream` caller and it skips `initiated_locally` rows
   by design. Sender rows accumulate unboundedly.
2. **Enumeration is O(history) over IPC.** `stream_infos` filters `retired` only; every
   dispatcher poll serializes the full historical row set to the brain merely for the
   client to skip local rows.
3. **Feed replay write-amplifies.** `serve_registry_feed` applies labels and instance flips
   PER 64KiB transport chunk; both apply paths end in `write_snapshots` (full rewrite of
   every subnet registry + heard.meta) — up to 2 full-state rewrites per chunk, synchronous
   in the brain's event loop. While the brain does this it stops draining IPC; the broker's
   seat writer blocks, hits the 15s SharedSend bound, poisons (12→15 observed
   CONN_WRITE_POISONED, family=Registry), and the dispatcher re-replays from seq 0.
4. **Completed seats retain OS threads — family-agnostically.** The broker conn loop
   accretes `my_stream_subs` per subscribe and releases only at conn-loop exit; there is NO
   net-stream unsubscribe verb. Every completed subscription on a long-lived carrier
   (Registry, sync, update alike) keeps its parked SubscriberSeat writer thread: ~546 of
   589 broker threads on the field box, unnamed.

Separately, the emphasys C2 report proved the inverse lifetime defect: an RC viewport whose
opener Brain dies WITHOUT FIN leaves its attach stream alive-and-durable forever — the
target never sees EOF, never detaches, and the controller slot + CONTROLLED stamps survive
even a full broker restart. Durability, which is load-bearing for inter-brain streams
(brain-swap), is wrong for connection-scoped viewers.

Both defect families are the same missing concept: **streams and seats have no declared
lifetime class**, so everything defaults to maximally-durable, and the only reclamation
point is connection close.

## Decision

<!-- [doc->REQ-ONEWAY-STREAM-TERMINAL] -->
1. **One-way (fire-and-forget) stream families are terminal at FIN, sender-side.** The
   registry feed pump retires its own row after successful write+FIN via the existing
   `net-stream-retire` verb (best-effort on N-1 brokers, per ADR-0038 A). A one-way
   family's exchange is definitionally over at FIN; keeping the row eligible reproduces
   the O(history) defect forever.
2. **Eligibility filtering is server-side.** `stream_infos` excludes `initiated_locally`
   rows (alongside `retired`) before serializing. Consumers keep their client-side guards
   (double-filter harmless; N-1 compatible both directions).
<!-- [doc->REQ-REGISTRY-APPLY-TRANSACTIONAL] -->
3. **Feed application is transactional per feed.** Registry replay merges decoded records
   in memory and writes snapshots ONCE at EOF (or one bounded batch commit for oversized
   feeds). Snapshot writes scale O(feeds), never O(chunks × record-kinds). No synchronous
   full-state rewrite inside a per-chunk event-loop iteration — this is the KH 7.12/7.43
   discipline (no blocking work where it stalls a drain loop) applied to the brain side.
4. **Poisoned one-way replays are bounded.** A deadline-poisoned Registry replay carries a
   per-stream strike budget; at budget the row retires TERMINAL, loudly. Safe by family
   semantics: a feed is an idempotent snapshot advertisement — the next pump round
   re-advertises. (Request/reply families keep ADR-0038 Amendment circuit-breaker
   semantics; terminal-at-budget applies to one-way families only.)
<!-- [doc->REQ-SEAT-LIFETIME-BOUNDED] -->
5. **Seats are released at serve completion — all families.** New broker verb
   `KIND_NET_STREAM_UNSUBSCRIBE`: removes the SubscriberSeat, stops and joins its writer
   thread, drops its cursor. Dispatch workers call it on serve completion (success or
   failure); the retire sweep calls it for retired rows. Unknown-kind on an older broker is
   the tolerated error path (best-effort caller). Physical resource lifetime on a
   long-lived carrier becomes O(active serves), not O(carrier lifetime).
<!-- [doc->REQ-STREAM-LEASE-CLASSES] -->
6. **Streams declare a lifetime class at open.** Two classes:
   - **ConnectionBound** — RC attach/view streams: opener connection EOF ⇒ the target sees
     FIN, `serve_attach` runs `detach_session`, controller/viewer stamps clear. A dead
     viewer can never pin a controller slot across its own connection's death.
   - **Durable** — inter-brain streams (attach continuity across brain-swap, transfer):
     unchanged ADR-0038 semantics. NEVER globally retire on Brain disconnect — brain-swap
     correctness depends on it.
   Late-close identity is validated (a stale opener A's close can't evict newer controller
   B) — rides the ownership/generation tokens from ADR-0038 Amendment fix 6.
7. **Spawned daemon threads are named** (SubscriberSeat writer `sub-writer-s<id>` minimum,
   conn accept handlers next) — field thread censuses must attribute, not infer.

## Consequences

- Steady-state broker cost on long-lived conns: threads, rows, and IPC enumeration all
  plateau at O(active). The field discriminators: hertz's preserved snapshot's poison count
  freezes; brain CPU drops to noise (with the self-hash fix, REQ-BRAIN-HASH-ONCE); broker
  thread census bounded and named.
- New wire surface: `KIND_NET_STREAM_UNSUBSCRIBE` (additive, best-effort) + a lifetime
  class on stream open (additive field; absent = Durable, so N-1 openers keep today's
  semantics exactly).
- The C2 endpoint-visible fix (controller slot freed on viewport death) lands in the
  ENDPOINT-LIFECYCLE wave as `REQ-STREAM-LEASE-CLASSES`, built on decision 6 — one
  mechanism, two waves, no double-build.
- Registry feeds keep at-least-once semantics; the strike budget makes worst-case loss one
  advertisement round (~30s), already the family's freshness unit.

## Requirements

`REQ-ONEWAY-STREAM-TERMINAL`, `REQ-STREAM-INFOS-SERVER-FILTER`,
`REQ-REGISTRY-APPLY-TRANSACTIONAL`, `REQ-REGISTRY-REPLAY-BOUNDED`,
`REQ-SEAT-LIFETIME-BOUNDED`, `REQ-HAZARD-REGISTRY-STALL` (KH 7.44),
`REQ-STREAM-LEASE-CLASSES` (W2), with `REQ-BRAIN-HASH-ONCE` riding the same wave
(independent defect, same field RCA).

## Amendment 1 (2026-07-22, DAEMON-LIFECYCLE W2) — teardown authority is opener-declared class PLUS transport liveness, enforced at the three places decision 6 could not see

<!-- [doc->REQ-STREAM-LIFETIME-CLASS] -->
<!-- [doc->REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT] -->

Decision 6 declared the two lifetime classes and shipped the CLEAN case: an
opener conn that exits takes its `ConnectionBound` rows down with it, the target
sees a FIN, `serve_attach` detaches, stamps clear. W2 asked the obvious next
question — *which orderings can a class-bound seat outlive?* — and measured
three, RED-first where a fix might have been owed. **Two of the three were
already closed by composition, and the third heals on the transport's own
timer.** Nothing in this amendment adds a mechanism; it records what teardown
authority actually IS across all three, with the scope limits stated rather
than implied.

**Teardown authority = the opener-declared CLASS + the TRANSPORT's liveness.**
Class alone cannot decide: it only separates rows whose conn is *already known
dead*. Liveness alone cannot decide: a `Durable` inter-brain row must survive
its opener. The three enforcement points below are where the pair is read.

### 1. Conn-exit sweep — shipped with decision 6, unchanged

The broker's conn-exit sweep FINs a `ConnectionBound` row and terminal-retires
it; cross-node, `conn.closed()` retires the conn's rows (`nethost.rs`
1000-1021). This is decision 6 as built.

### 2. Restart replay — measured, NO filter owed (Leg A)

ADR-0038 decision 2 makes classification restart-durable, so a fresh dispatcher
generation re-serves every retained opener Attach the broker still enumerates.
A **`ConnectionBound` opener replaying is a class contradiction** — the conn
that declared the class cannot exist after the death that killed it — so this
looked like a leak needing a class filter at enumeration.

**It is closed by composition, measured RED-first** (`restart_replay_lifetime.rs`,
in-process duplex): sweep → FIN → `finished=true` →
`finished_row_is_terminal(Attach, true)` → the row is retired UNSERVED by the
next generation. The seat does not re-establish. No filter, no wire field.

**ADR-0038 decision 1 holds through its LIFECYCLE-STATE arm, NOT its
enumeration arm — and this is a named reasoning hazard.** The dead opener's row
is STILL enumerated as claimable after the sweep; it is merely `finished`. What
stops the replay is retire-on-sight in the WORKER (`dispatch.rs`), DOWNSTREAM of
the claim. Two designers in one week reasoned from the arm that is not
load-bearing — a broker-side filter "at enumeration" is the intuitive placement
precisely because decision 1 reads as if enumeration already excludes. It does
not. Any future filter on this seam belongs where the exclusion actually
happens, and its key is **"is this row's transport conn alive"**, not the class.

**Scope limit, stated:** Leg A rode the IN-PROCESS DUPLEX (see the naming split
below). The cross-node arm reaches the same outcome by a DIFFERENT mechanism
(`conn.closed()` row retirement) and was not exercised there.

### 3. Transport death without a FIN — measured, heals on the QUIC idle timeout (Leg B)

The half-open case decision 6 could not reach: the opener's conn is never
closed, because the peer's QUIC stack simply stops. Never measured on real QUIC
before W2 — the standing "clears on NEITHER platform at ANY window"
characterization was taken on the in-process duplex, where this shape is
**unstageable by that code's own comment**.

**Measured** (`transport_death_eof.rs`, real QUIC, two samples):

| leg | seat released | row left the table |
|---|---|---|
| clean FIN (baseline + stop condition) | 120ms / 115ms | 120ms / 115ms |
| torn, no FIN | 65057ms / 65047ms | 65027ms / 65021ms |

The already-serving `serve_attach` worker DOES receive the EOF: the read pump's
torn end (`Err(_) => finish()`) finishes the log exactly like a clean end, and
`StreamLog::finish` pushes the EOF envelope to the seat installed on that same
`Arc`'d log. Row removal cannot break an already-serving worker; it only breaks
NEW lookups. The ~65s is `MESH_MAX_IDLE` (60s) plus quinn's PTO slack — a number
materially UNDER 60s would have meant the staging leaked a signal, so the
above-60s landing is what makes the measurement trustworthy rather than merely
favourable.

**Staging class — what the number is a number ABOUT.** The peer models a
**QUIC-STACK-DEAD** host: its net runtime is frozen, so the endpoint is alive
and its UDP port stays BOUND while nothing is ever sent or answered. That
distinction is load-bearing and asserted, not assumed: a bare host drop or a
process kill CLOSES the socket, which answers keepalives with ICMP
port-unreachable — a refusal, not silence, and a different (faster) shape than
the field's sleeping laptop. The rig asserts the port is still bound after the
sever, and proves the freeze landed by watching the net runtime's reactor canary
go stale (0ms → 2006ms) after asserting it was fresh beforehand — a capability
probe on the instrument itself, in the RC-RENDER-TRUTH oracle discipline.

**DECISION, taken ON the number: ACCEPT AS DOCUMENTED.** No presence-FIN
synthesis, no keepalive tune. Grounds:

1. **The latch is not a lockout.** A different-conn attach during the window is
   an ADR-0038 fix-6 successor and takes the seat immediately — the returning
   operator, or a second one, is never blocked by the dead seat.
2. **The residual harm is stale status truth** (CONTROLLED / `driven_by` /
   viewer count) for ~65s after an abrupt transport death — a rare event that
   self-heals on a bound.
3. **Synthesis would add a SECOND teardown mechanism** to shave ~60s off a case
   that already converges, and every second mechanism on this seam is
   historically where the bugs lived.
4. **A keepalive tune is a GLOBAL QUIC tradeoff** (chatter, battery, mobile
   paths) and is not spent on a cosmetic minute.

The revisit lever is NAMED, not built: if the window ever reads as bad product
feel, the tune is the keepalive interval, and it is a product-feel call with a
false-evict bound to design — not a bug fix.

`REQ-HAZARD-DRIVEN-BY-IDLE-REMOTE-EVICT` therefore stays `int`-only: the
measurement is the deliverable, and impl/unit are NOT owed on this evidence.

**Boundary, stated not implied (KH 7.15 / 7.29 carry it too):** this closes the
DEAD-transport-no-FIN half only. The **ALIVE-but-WEDGED** controller (KH 7.15
A2 — the QUIC stack keeps answering keepalives while only the app writer is
parked) never fires an idle timeout at all, so nothing in the transport-death
class reaches it. That leg stays deferred, and a green transport-death rig must
never be read as covering it.

**Confirmation outstanding:** both legs above were measured on local real QUIC
(two brokers, one box). LAN confirmation over a real two-host path is insurance
on a decision that builds nothing, and rides the next box-occupancy window with
the gate battery.

### Rejected alternative: a lifetime field on the opener reply

Proposed (todlando) and rejected on a REACH FACT that was not checked before
proposing it: **the row a dispatcher re-serves is not the classed row.** The
served row is the ACCEPTED row (cross-node) or the loopback PEER row, and both
register `Durable` — class-blind BY DESIGN, because the lifetime class is
OPENER-side (decision 6): a target experiences a `ConnectionBound` teardown as
an ordinary FIN, never as a class of its own row (`nethost.rs` 1028-1041,
1763-1771).

A lifetime field on the opener reply would therefore report `Durable` for
**exactly the population such a filter exists to catch** — undercounting the
leaked rows to zero while spending wire surface on the wrong rows. Target rows
staying class-blind is CORRECT and must not be "fixed"; two classes suffice, and
no third class is introduced here.

Generalised: **check WHICH ROW the code under test actually serves before
designing a carrier to describe it.**

### Rig-craft clauses this wave paid for

Recorded here because all three are oracle-shape rules, and this ADR is where
this seam's rigs are designed:

- **The two senses of "loopback" are different transports.**
  `net_dial_loopback()` / `open_loopback_pair` produce `RecvHalf::Loopback`, an
  IN-PROCESS DUPLEX with no QUIC and **no idle timeout at all**;
  `BindScope::Loopback` plus a real `net_dial` to a bound addr produces
  `RecvHalf::Quic`, a real read pump and the real `MESH_MAX_IDLE`. A rig must
  pick its venue DELIBERATELY, never by the word. This single distinction is why
  the no-FIN case read as unstageable, and it retires that whole class of
  mis-staging.
- **A verdict must not ride a channel that is silent on success.** The
  dispatcher's `DISPATCH:<id>:<outcome>` line is emitted ONLY on the
  non-`Served` arm, so a worker that serves an attach and exits on an EOF prints
  NOTHING. Timing it as progress made the Leg B rig fail its own clean leg —
  caught by the mandatory sibling probe before any torn number could be banked.
  It is an ANOMALY channel; keep it as a "the baseline is not a failure path"
  assert and observe progress elsewhere.
- **Decision 2's server-side filter blinds every brain client to opener rows.**
  `net-streams` over IPC excludes `initiated_locally` rows, so a test that needs
  to see an opener row must read in-process off the `Arc<Broker>`. That is the
  rig shape for this seam, not a workaround.
