HydraIssues

DESIGN 6 (proposal): the stream is the unit, not the body slot
open unclassified Priority: high Project: hydracluster Parent: #663 Reporter: 18 Sep 2026 11:37

Description

Design 6: The stream is the unit

A sixth architecture for #663, proposed 2026-09-18 by the owner, written up
against the five in body-allocation-and-pairing-reasoning.md and checked
against the 46 recorded adversarial breaks.

Read body-allocation-and-pairing.md (the chosen design, Fenced Slot Leases)
and this document together. This is not a replacement spec. It is a claim about
what the unit of allocation should be, and an honest account of which recorded
breaks it dissolves, which it inherits, and which it makes worse.


The claim

A stream is the durable entity. It needs a head and a body. A head does not
acquire a body; a head starts a stream, and the stream acquires a body.

Concretely:

  • A visitor taps an experience. A stream is created and the head is told it
    is attached to that stream. This happens before any body is chosen.
  • The stream then looks for a body. While it looks, the head shows "connected,
    finding a body", or a queue position. Both are representable because the
    stream exists.
  • A body is bound to the stream. The stream runs.
  • If the head disconnects, it reconnects to its stream, by stream identity.
  • If the body dies, the stream survives and looks for another body.

The five existing designs all take the (body, slot) as the unit and model
the head's hold on it as a lease. This one takes the stream as the unit and
models the body as a resource the stream binds to.


Why: the root cause is one line

pkg/api/session_store.go:32

active map[string]*SessionRecord // keyed by body ID

with openSession(bodyID, bodyName, streamingClientUUID string).

The session has no identity of its own. It is the body's current session.
Every symptom in #663 follows:

Symptom Because
Three heads collapse into one row openSession returns early when a record exists for that body. The bad state is unrepresentable, which is why it ran undetected from #308 through #530 to #660
No queue, no "finding a body" A stream cannot exist before a body, so there is nothing to hold, show or order
A body dying kills the visit The session is the body row
Reconnect is /resume on a shared uniqueid A head has no stream identity to reconnect to, so it re-asserts a device identity instead, and head B is handed head A's live RTSP session

The design already names the last one as the mechanism that turns a race into
the observed symptom. It is downstream of the key.


What this dissolves

Break at reasoning.md:2757 — reclaim latency and fairness (FATAL)

"freed capacity is not re-granted for an unbounded time, and when it is
re-granted the winner is whoever happens to touch a screen, not whoever has
waited longest. Reclaim latency is unbounded and fairness is zero."

The break observes that the 409 carries retry_after_seconds: 10 and that no
head in the fleet has a retry loop
: the iPad's tick() is case .error: break, and both discovery failure and pairing failure set .error.

This is a direct consequence of the unit. A denial is terminal because there is
nothing for the head to hold on to. Under Design 6 a denial is not a denial: it
is a stream in state seeking_body. The head is already attached to something,
so retry is the stream's lifecycle rather than a client loop that must be
written identically in three codebases (Swift, Kotlin, Go) and kept in step.

Ordering falls out for free. Streams in seeking_body are a queue, so
"whoever waited longest" becomes answerable instead of undefined.

Reconnect stops being hijack

Today reconnect and hijack are the same operation, distinguished only by a
device identity that is 0123456789ABCDEF on every device in the fleet. Under
Design 6 the head reconnects by naming its stream. A head that names a stream
it does not hold is refused, and that refusal is a detectable event rather than
a successful join.

A body failure stops being a visit failure

If the stream is the unit, a body dying mid-visit returns the stream to
seeking_body. It can be re-homed. This failure mode is unrepresentable today
and no design addresses it, because in all five the lease names the body and
losing the body loses the allocation.

The body's job shrinks to what the design already says it should be

The design's own rule is that the body "may close admission" but "may never
kill a live stream", and that it "is the only authority on whether a stream
runs". Under Design 6 the body is told "serve stream X" and reports what it
observes. It is never the authority on who may run a stream, only on
whether one is running. That is the rule the design states and the lease
model then partially violates by making the body renew leases.


What it inherits unchanged

Break at 2749 — the unattended stream (FATAL)

"a body that keeps Sunshine streaming with nobody watching holds a slot
indefinitely. That is deliberate."

Design 6 makes this representable where today it is not: a stream whose
head has not been seen for N seconds while its body still reports streaming is
a queryable state, not an inference. But representable is not bounded. The
maxUnattendedStream ceiling (Design 2's 30 minutes) is still required, and
Design 6 does not remove the need for it. Inherit the fix verbatim.

The pre-stream blind window

Between "bound to a body" and "first frame" there is pairing, getservercert
(which alone can block 180s per HttpManager.m:194), and app launch. No stream
exists to observe, and a head that dropped looks exactly like a head that is
slow. This is why startDeadline is 210s. It is a Moonlight property, not an
architecture property, and Design 6 does not shrink it. Both models fall back
to a timer here.

Break at 2839 — identity, and a correction to this proposal

"the lease no longer names the device that will actually stream ... It also
breaks the design's own stated rule that 'the claim must be
head-authenticated, not head-asserted'."

This break invalidates the first shape I reached for. A head minting its own
stream id and presenting it to whichever authority grants a body is precisely
head-asserted. Corrected:

  • The stream id is minted by the granting authority, never by the head.
  • It is bound at creation to a registered device identity, so the stream
    names the device that will actually stream.
  • The head holds a per-stream secret issued to it, not one it invented.
    Rotated when the stream is rebound to a different body.
  • Reconnect presents (stream id, stream secret). Neither is a device identity,
    so a stolen device that still holds a valid client certificate does not
    thereby hold anyone's stream.

Design 6 therefore depends on the device-identity registry that break 2839
demands (POST /api/v1/heads/{id}/device-identity, admin-only revoke,
revocation propagated on the body status response). It does not replace it.


What it makes worse

Locality and fairness, if the grant also moves to the body

Break at 2741 (FATAL) is that a bodyless venue can indefinitely hold 100% of
another venue's owned capacity. Sint-Niklaas owns no body; Rupelmonde owns two.
The proposed fix is a home_venue_reserve per body, max_concurrent_grants
per venue and per org, and cross-venue leases preemptible while still pairing.

Those are policy. A central authority applies them trivially. A body
granting its own admission knows only what it has been told, so every one of
those knobs becomes configuration that must be pushed to bodies and kept
fresh, and a body with stale policy makes locally-correct, globally-wrong
decisions.

This is the real cost of moving the grant outward, and it is not small. It
argues for the split: discovery and policy stay central; admission is local.
The cluster answers "which bodies may serve you, in what order, under what
reserve"; the body answers "yes or no, now". The body never invents policy, it
only refuses.

One head, two pre-stream streams

A head that can reach two bodies could hold a seeking_body stream on each if
admission is purely local. A central authority sees both; two bodies do not.
Mitigation is one outstanding stream per registered device, enforced wherever
streams are minted, which is an argument for minting them centrally even if
admission is local.


Migration: per head, per body

This is the property that makes Design 6 attractive operationally, and it is
the owner's second request.

  1. Change the key. active becomes keyed by stream id, with a body index
    beside it. Nothing else changes. openSession stops swallowing the second
    head, and the collision becomes representable and countable before any
    behaviour changes. This is hydracluster-only, no client release, and it is
    the smallest change that makes the Sint-Niklaas defect visible.
  2. Mint streams. Creation returns (stream id, stream secret) alongside the
    body. Old heads ignore both fields; new heads store them.
  3. Per body: a body begins honouring "serve stream X" and refusing unknown
    streams. Bodies flip independently. An old head simply gets refused and
    falls through to the next candidate, because a body's answer is
    authoritative regardless of what the head believes. This is the property
    cluster-side leases do not have: they require fleet-wide agreement on the
    lease concept before they bind.
  4. Per head: a head begins reconnecting by stream id instead of /resume
    on the shared uniqueid. Heads flip independently. Until a head flips, it
    behaves exactly as today.
  5. Locality policy, queue ordering and the unattended ceiling land after,
    against a model that can already represent them.

Steps 1 and 2 are server-only. Step 3 is per body. Step 4 is per head. No step
requires the fleet to move together, which is the failure mode the reasoning
doc repeatedly identifies in the mixed-fleet windows of the other designs.


Open questions

  1. Where are streams minted? Central minting answers the two-streams
    problem and break 2839's head-asserted objection. Local minting survives a
    cluster outage. A middle path is central minting with a body-issued
    emergency stream when the cluster is unreachable, reconciled on reconnect,
    and that path needs its own break analysis before anyone builds it.
  2. Does the stream survive a body swap mid-visit, or only pre-stream?
    Re-homing a live stream means re-pairing and relaunching on the new body.
    Attractive, and not free.
  3. What is the queue's unit of fairness? Per venue, per org, or global.
    Break 2741 says this cannot be left unstated.
  4. Does this change the answer to body-granted admission, or is it
    orthogonal? The claim here is that it is orthogonal: stream-as-unit is about
    the noun, body-granted is about the location of the verb. They compose, but
    each should be judged separately.

Status

Proposed. Not judged against the three scorecards the other five designs were
scored on, and not itself adversarially attacked. Before it is adopted it
should be, because the value of the existing design is that it survived 46
recorded breaks and this one has survived none.