# Virtual-display management & lifecycle policy — design

> **Status (2026-07-05):** **Stages 0–5 (§6A) DONE + on-glass validated; keep-alive reconnect
> hardened** (branch `display-mgmt-stage0`, not yet merged). Stage 5 §6A: display **groups**
> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware
> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group
> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the
> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, the
> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console
> arrangement table** — **live-validated on KWin `.116` + Mutter `.21`** (group model, positions,
> identity keying, group-aware exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors). The
> Stage-3 **KDE scaling round-trip is now proven live** (set 150 %/125 % → disconnect → reconnect →
> reapplied, seen in `kwinoutputconfig.json`). **Keep-alive reconnect hardening (`b53710d`, on-glass
> validated with the probe):** a same-client reconnect **preempts its own zombie**
> (`admission::preempt_same_identity` — fixes "reconnect within the idle-detection window lands on a
> fresh SECOND display while the old one keeps streaming"), a **deliberate quit skips the linger**
> (client closes with `QUIT_CLOSE_CODE` 0x51 → `registry::release(force_immediate)`; §5.1), and the QUIC
> control-connection idle timeout (the disconnect-detection latency) is **host-tunable**
> (`PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`, default 8 s). **Remaining Stage 5 = hardware-gated
> residuals only**: the per-group physical-restore EFFECT (needs a monitor-attached Linux box — the
> headless validation boxes report `also_disabled=[]`, so nothing is disabled to restore), wlroots
> `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert. See the **Status —
> handoff** block under §11 for the per-stage state and key decisions (notably the Windows `reject`
> default).
> This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
> client wants a different mode), stable display identity (so desktop environments remember
> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one
> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend
> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how
> long* to keep, *what else* to do to the topology, and *under which identity*.

Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
not required), **`design/gamemode-and-dedicated-sessions.md`** (PLANNED — reconciles this layer
with session-mobile Bazzite/SteamOS hosts: display **ownership classes** so the registry stops
pooling gamescope managed/attach outputs it doesn't own, validated reuse + invalidation, the
§5.1 "policy replaces the managed 5 s debounce" promise actually implemented, and the dedicated
per-launch gamescope game sessions built on it).

## 1. Goal

Today the virtual-display behavior is hardcoded per platform and per backend:

- A session's virtual output is created at connect and torn down (RAII) at session end — a
  disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills
  the running game**.
- "Make the streamed output the sole desktop" is an env knob on Linux
  (`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the
  auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) —
  and on Linux "primary" and "disable the other outputs" are conflated into one switch.
- What happens when a second client connects is an emergent property of the platform: Linux
  creates a second output (multi-view), Windows **reconfigures the shared monitor under the
  live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream
  preempts.
- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only
  Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every
  session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect
  and is shared across every client.
- One session = exactly one display. A client with two physical monitors can only stream one;
  a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux
  multi-view behavior half-does it by accident, with no layout control).

Goal: **one shared, documented configuration surface** — a small set of orthogonal options with
safe defaults and selectable presets, stored host-side, editable from the web console, applied
uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin,
gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and
**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit).

## 2. What exists today (inventory)

The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.**

| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope |
|---|---|---|---|
| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session |
| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) |
| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session |
| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) |
| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client |
| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a |
| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session |

Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine
Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership
split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy
in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the
slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system.

## 3. Architecture

Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite):

```
                       ┌────────────────────────────────────────────┐
   mgmt API / console  │  DisplayPolicy  (vdisplay/policy.rs)       │  pure config: schema,
   host.env compat ───▶│  presets · layout · validation · persist   │  presets, env-compat
                       └───────────────┬────────────────────────────┘
                                       │ read per acquire/release (live-reload)
                       ┌───────────────▼────────────────────────────┐
   punktfunk/1 session │  DisplayRegistry (vdisplay/registry.rs)    │  host-lifetime singleton:
   GameStream session ─▶  acquire(identity, mode) → DisplayLease    │  owns ManagedDisplay slots
   mgmt /display/state │  release(lease) · linger timer · groups    │  grouped per desktop,
                       └───────┬────────────────────────┬───────────┘  drives the pure Lifecycle
                               │ create()/drop keepalive │ reconfigure/topology/layout ops
                  ┌────────────▼──────────┐   ┌──────────▼───────────────┐
                  │ Linux backends        │   │ Windows                  │
                  │ kwin · gamescope ·    │   │ VirtualDisplayManager    │
                  │ mutter · wlroots      │   │ (existing; delegates its │
                  │ (unchanged trait)     │   │ state decisions upward)  │
                  └───────────────────────┘   └──────────────────────────┘
```

- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON
  persistence (`<config>/display-settings.json`, the `gpu-settings.json` pattern: sanitize on
  load, atomic tmp+rename write), and the deprecated-env-knob mapping. 100 % pure and
  unit-tested (the `pick_gamescope_mode` / `wiring_plan.rs` discipline).
- **`vdisplay/lifecycle.rs`** — the pure state machine: per-slot
  `Idle / Active{refs} / Lingering{until} / Pinned` plus the **admission decision function**
  (given: policy, requesting identity, requested mode(s), current slots → `Create | Reuse |
  Reconfigure | Join{at_mode} | Steal{victims} | Reject{reason}`). No I/O, no OS types — fully
  proptest/unit-testable, shared verbatim by both platforms. `Pinned` is `Lingering` with no
  deadline (keep-alive **forever**), releasable only via mgmt/teardown.
- **`vdisplay/registry.rs`** — the host-lifetime singleton that owns `ManagedDisplay` slots
  (the backend `VirtualOutput` **including its `keepalive`**, the identity slot, current mode,
  group membership, topology-restore state) and executes the lifecycle decisions: calls
  `VirtualDisplay::create`, holds keepalives past session end, runs the linger timer, applies
  layout, exposes the mgmt snapshot. On Windows it wraps the existing `VirtualDisplayManager`
  (which keeps its driver/CCD/preempt specifics — the IDD dead-swapchain preempt, the
  WUDFHost-death preempt, `begin_idd_setup` — but reads its linger duration and join/steal
  behavior from the policy instead of env/hardcode).

### The ownership split (the one real refactor)

Today `capture::capture_virtual_output(vout, …)` consumes the whole `VirtualOutput` — the
capturer owns the keepalive, so capturer drop tears the display down. That coupling is exactly
what makes keep-alive impossible on Linux. Split it:

```rust
pub struct DisplayLease { /* registry handle + gen stamp; Drop = release(refcount--) */ }
pub struct CaptureSource {          // what capture actually needs — Copy-ish, no ownership
    pub node_id: u32,
    pub remote_fd: Option<OwnedFd>, // Mutter portal daemon (dup'd per capture attach)
    pub preferred_mode: Option<(u32, u32, u32)>,
    #[cfg(windows)] pub win_capture: Option<WinCaptureTarget>,
}
// registry.acquire(...) -> (DisplayLease, CaptureSource)
```

The `keepalive: Box<dyn Send>` moves into `ManagedDisplay` inside the registry. The session's
pipeline holds the `DisplayLease` (mirrors the Windows `MonitorLease`, gen-stamped so a stale
lease from a preempted display is a release-no-op — the proven pattern). `build_pipeline`'s
`vd.create(mode)` call sites (`punktfunk1.rs`, `gamestream/stream.rs`, `spike.rs`) become
`registry::acquire(...)`. Every failure/retry path keeps its shape — the retry-hold lease trick
in `build_pipeline_with_retry` maps 1:1 onto a `DisplayLease`.

**Re-capture on reuse** is per-backend (see §7): wlroots re-runs portal capture of the still-
existing output; KWin/Mutter reconnect a PipeWire consumer to the kept node (validation item);
gamescope re-discovers the nested compositor's node; Windows already re-targets. If re-capture
of a kept display fails, the registry falls back to **teardown + fresh create** (bounded, inside
the existing `build_pipeline_with_retry` budget) — keep-alive is an optimization, never a new
failure mode.

## 4. The configuration surface

### 4.1 Schema (`<config>/display-settings.json`)

```json5
{
  "version": 1,
  // Convenience: a named preset. "custom" (or absent) = the explicit fields below rule.
  // When a preset IS named, the fields below are ignored (the console writes one or the other).
  "preset": "custom",

  // How long a display (and, on gamescope, the nested session + game) survives after the last
  // session detaches. "off" = teardown at session end. "forever" = until host stop / explicit
  // release. Duration is seconds.
  "keep_alive": { "mode": "duration", "seconds": 300 },   // "off" | {"duration", seconds} | "forever"

  // What the host does to the box's display topology while virtual displays are up:
  //   "extend"    – add the virtual display(s), touch nothing else
  //   "primary"   – make the group's primary virtual display the OS primary; physical outputs
  //                 stay enabled
  //   "exclusive" – the managed virtual displays become the ONLY enabled outputs (physicals
  //                 disabled, restored when the group's last display is torn down)
  //   "auto"      – today's behavior: exclusive on the auto-detected desktop path & Windows,
  //                 extend when the operator pinned a compositor/env said otherwise
  "topology": "auto",

  // Admission when a client connects while another client's display/session is live and the
  // requested mode differs (same-client reconnect ALWAYS reuses/reconfigures its own display):
  //   "separate" – give the new client its own virtual display ON THE SAME DESKTOP (bounded by
  //                max_displays) — this is also the "many clients as monitors" mode, see §6A
  //   "steal"    – stop the existing session(s), tear down / reconfigure, serve the new client
  //   "join"     – admit the new client AT THE EXISTING MODE (Welcome/serverinfo reflect the
  //                real mode — the honest-downgrade convention); never reconfigures under a
  //                live session
  //   "reject"   – refuse the new client with a clear handshake error
  "mode_conflict": "separate",

  // Stable display identity → desktop environments persist per-display config (KDE scaling):
  //   "shared"          – one identity for everything (today's Linux behavior)
  //   "per-client"      – one identity per paired client cert fingerprint (today's Windows);
  //                       a multi-display client (§6B) gets one identity per (client, display #)
  //   "per-client-mode" – one identity per (client, WxH) — distinct scaling per resolution,
  //                       at the cost of identity slots (Windows has 15; LRU eviction)
  "identity": "per-client",

  // How the group's displays are arranged in the desktop coordinate space (§6.2):
  //   "auto-row" – left-to-right in acquire order, top-aligned (deterministic default);
  //                a §6B client's own monitor-arrangement hints override auto placement
  //   "manual"   – per-identity-slot offsets below (console-arranged); wins over client hints
  "layout": { "mode": "auto-row", "positions": { /* "<slot>": {"x": 0, "y": 0} */ } },

  // Upper bound on simultaneously-live virtual displays (Active + Lingering + Pinned, across
  // the whole group). Admission returns Reject/Steal (per mode_conflict) when full; a §6B
  // AddDisplay beyond it is declined. Windows is additionally capped by the driver (see §7).
  "max_displays": 4
}
```

Deliberate non-options (rejected):

- **Per-client policy overrides** — real, but v2. One host-global policy first; the schema keys
  are chosen so a later `"clients": {"<fp>": {…}}` overlay is additive.
- **Idle timeout for Pinned displays** ("forever but tear down after 24 h") — `keep_alive`
  already expresses it as a long duration; don't add a second axis.
- **Choosing the linger for capture-loss separately from clean disconnect** — the registry only
  sees "last lease released"; the session layer already distinguishes and (see §5.1) an explicit
  client **quit** bypasses keep-alive entirely.
- **Per-display FEC/bitrate policy knobs** — bitrate stays session-negotiated per stream as
  today; a multi-display session's per-display bitrates are the client's ask, not host policy.

### 4.2 Precedence & live-reload

`display-settings.json` (console-written) **>** deprecated env knobs **>** built-in defaults —
the exact precedence convention the GPU preference set (`console preference >
PUNKTFUNK_RENDER_ADAPTER > auto`). The policy is **read at each acquire/release**, not once at
startup (it's file/registry state, not env — no `HostConfig` constraint), so a console change
applies to the next connect/disconnect without a host restart, same contract as the GPU card
("applies to the next session"). Env-knob compatibility mapping (all logged as deprecated when
they take effect):

| Legacy knob | Maps to |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | `keep_alive = duration(ms/1000)` (Windows) |
| `PUNKTFUNK_NO_ISOLATE` | `topology = "extend"` (Windows) |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `topology = "exclusive"` when truthy, `"extend"` when explicitly `0` |

The `apply_session_env` default-on write of `*_VIRTUAL_PRIMARY` for the auto-desktop path is
**replaced** by `topology = "auto"` resolving to exclusive on that path — one fewer process-env
mutation on the connect path (a small win for the env-race surface `ENV_LOCK` guards).

### 4.3 Presets

Presets are the documented, supported entry point; raw fields are the escape hatch. Expansion
lives in `policy.rs` and is unit-tested so docs and code can't drift.

| Preset | keep_alive | topology | mode_conflict | identity | layout | Story |
|---|---|---|---|---|---|---|
| `default` | 10 s | auto | separate | per-client | auto-row | Today's behavior, made explicit: short linger absorbs client hiccups/reconnects, streamed output is the sole desktop on the auto path, extra clients get their own view. |
| `gaming-rig` | forever | exclusive | steal | per-client | auto-row | Dedicated headless/couch box: the game and its display survive disconnects indefinitely; whoever connects takes the box over ("the TV model"). |
| `shared-desktop` | off | extend | separate | per-client | auto-row | Streaming a desktop someone may also use physically: never blank the real monitors, never keep ghost outputs, concurrent viewers each get a view. |
| `hotdesk` | 5 min | exclusive | reject | per-client-mode | auto-row | One user at a time with fast reattach (roaming between own devices); a second user is told the box is busy; each device+resolution keeps its own scaling. |
| `workstation` | 5 min | exclusive | separate | per-client | manual | The multi-monitor daily driver: your dual-monitor client gets both displays back exactly where you arranged them (§6B), or a tablet joins as a side monitor (§6A). |

## 5. Option semantics in detail

### 5.1 `keep_alive`

**What survives.** The *display* (compositor output / IddCx monitor / spawned gamescope) and its
topology state survive; the *session* (QUIC conn, capture stream, encoder, input devices, audio
plumbing) does not. Concretely per backend, "the display survives" means:

- **kwin / mutter / wlroots**: the output stays in the layout → windows don't reshuffle, a
  running game keeps rendering at the client's mode, reconnect is fast (no create/negotiate).
- **gamescope (bare spawn)**: the nested gamescope **and the game launched inside it keep
  running** — this is the headline user value (Sunshine/Apollo-style detach/reattach) and the
  reason `keep_alive` is worth building at all.
- **gamescope (managed)**: the policy duration replaces the hardcoded 5 s
  `RESTORE_DEBOUNCE` — the warm Steam session stays up for the window; `forever` means the TV
  session is never auto-restored (release via console/tray).
- **Windows**: the existing linger, plus `forever` = the `Pinned` state — **shipped** (`ccbd7e8`,
  `MgrState::Pinned`; compile-verified on `.173`, on-glass Windows Pinned pending). Freed via
  `POST /display/release` (`force_release` handles Pinned) — the §8 escape hatch. `gaming-rig` (the
  `forever` preset) is no longer mgmt-rejected and is enabled in the console; **on-glass validated on
  Linux** (`.116` KWin: normal disconnect → `pinned`, no expiry; Release frees it).

**Rules.**
- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to
  the game as "controller unplugged", which games handle. (Keeping pads alive for kept sessions
  is a possible later refinement; do not build it now.)
- The **launch command runs once per display creation, never per attach** — a reconnect to a
  kept gamescope must not double-launch the game. Today launch already happens once per
  `build_pipeline`-successful session; the invariant moves with the create into the registry.
- An explicit client **quit** (a user "stop", not a network drop) bypasses keep-alive: tear down
  now. **Implemented on punktfunk/1** (`b53710d`, on-glass validated): the client closes the QUIC
  connection with `QUIT_CLOSE_CODE` (0x51, shared in `core::quic`); the host reads the
  `ApplicationClosed` reason and does `registry::release(force_immediate)` → `Linger::Immediate` →
  teardown, skipping the linger. `NativeClient::disconnect_quit()` + `punktfunk-probe --quit` drive
  it; GameStream `cancel`/quit-app (`h_cancel`) + the five real clients sending the code are
  follow-ups. A plain disconnect / connection loss honors the policy (lingers for reconnect).
- A **same-client reconnect resumes** (never a fresh second display). A reconnect while the client's
  own prior session is still `Active` — its QUIC idle timer hasn't fired, and detection lags a drop by
  `max_idle_timeout` (default 8 s, host-tunable via `PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`)
  — is recognised by `admission::preempt_same_identity` (same cert fingerprint): the host signals the
  zombie's stop + waits the release grace, so it lingers and the reconnect **reuses** the kept display.
  Without this, a reconnect inside the detection window landed on a fresh second display while the old
  session kept streaming. **Implemented + on-glass validated** (`b53710d`); implements the "preempts
  downstream" the admission layer already promised (§5.3).
- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever
  the OS reclaims — Wayland connections die with the process (compositor reclaims outputs),
  spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when
  pings stop. No new orphan class.
- `keep_alive` + `topology=exclusive` means **physical monitors stay dark after disconnect**
  until linger expiry / release. This is intended (gaming-rig) but must be loud in the docs, and
  the release-now escape hatch (§8) must exist in the same release that ships `forever`.

### 5.2 `topology`

Splits the currently-conflated "primary" knob into three honest levels, **group-aware** (§6.1):
"exclusive" means *the managed virtual displays* are the only enabled outputs — never disable a
sibling slot; restore fires when the group's last display drops. Per-backend mapping:

| | extend | primary | exclusive |
|---|---|---|---|
| KWin | no-op | `kscreen-doctor output.X.primary` only | primary + disable non-managed others (today's `apply_virtual_primary` with a registry-driven filter, §6.1), restore-on-teardown |
| Mutter | no-op | `ApplyMonitorsConfig` incl. physicals, virtual primary | today's sole-monitor config (`make_virtual_primary`) extended to include all group members |
| wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output <phys> disable` + re-enable on teardown (new, small) |
| gamescope | n/a — the nested session *is* the whole world; all three resolve to no-op | | |
| Windows | skip isolate (today's `PUNKTFUNK_NO_ISOLATE`) | CCD primary-only variant (new, small — `set_active_mode` already exists; primary without deactivation) | today's `isolate_displays_ccd`, extended to isolate to the SET of managed targets |

Restore stays bound to **display teardown** (keepalive drop / `teardown()`), not session end —
already true everywhere; keep-alive inherits it for free. The KWin restore-before-reclaim
ordering (re-enable others *first* so KWin never sees zero enabled outputs) is preserved.

`auto` resolves at acquire time: exclusive on Windows and on the Linux auto-detected-desktop
path, extend under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test posture) — bit-for-bit
today's defaults, so `default` preset = no behavior change.

### 5.3 `mode_conflict`

Enforced at **admission**, before the Welcome / RTSP launch, in the lifecycle decision function
— so the client gets an honest answer, not a mid-build failure:

- Applies only across **different clients** (identity ≠ identity). A same-client reconnect
  always preempts its own zombie session / adopts its own kept display and reconfigures it to
  the newly requested mode (today's behavior, now uniform on all platforms).
- `separate` — allocate another slot in the desktop group (Linux multi-view today, upgraded
  with layout — §6A; Windows: **requires the multi-monitor manager, §6.6** — until that stage
  lands, `separate` on Windows resolves to `join` with a startup + docs warning rather than
  silently doing something else).
- `join` — the second client is admitted at the live display's mode. punktfunk/1: the Welcome's
  `Config` carries the real mode (the client already renders what the Welcome says — the
  4:4:4/10-bit honest-downgrade pattern). GameStream: serverinfo/RTSP negotiate the live mode.
  **This replaces the Windows join-path's silent last-wins `reconfigure` under a live session**
  — that current behavior becomes opt-in as `steal`.
- `steal` — signal the victim sessions' stop flags (the machinery `begin_idd_setup` already
  uses), wait the release grace, tear down or reconfigure, admit. Trust note: conflict policy
  runs **after** the pairing gate, so on a default host only paired clients can steal; on an
  `--open`/TOFU host any accepted client can — the docs call this out and recommend `reject`
  for open hosts.
- `reject` — punktfunk/1: a typed handshake refusal (extend the existing error path with a
  `busy` reason string carrying the live mode + client label so the client UI can say "host is
  streaming 2560×1440 to <name>"); GameStream: the 503/session-in-use answer Moonlight already
  understands.

Interaction with `--max-concurrent` (session bound) is unchanged and orthogonal: sessions and
displays are different resources; `max_displays` bounds displays, the accept-loop permit bounds
in-flight sessions. `join` deliberately lets N sessions share one display (that's today's
Windows concurrency model).

### 5.4 `identity` — stable displays, persistent scaling (the KDE ask)

Two halves: an **identity map** (who gets which slot) and a **per-backend identity carrier**
(how a slot becomes something the DE keys its config on).

**Map** — generalize `vdisplay/windows/identity.rs` (it's already pure + unit-tested) into a
platform-neutral `vdisplay/identity.rs`: key = client cert fp (plus display ordinal for a §6B
multi-display client, plus WxH under `per-client-mode`), value = small stable slot id, LRU
eviction at the platform cap, persisted `<config>/display-identity.json` (Windows migrates
`pf-vdisplay-identity.json` on first load — read old path if new absent, write new).
Anonymous/unpaired clients stay slot 0 = auto/shared. **GameStream clients get identities too**
(improvement over today): the paired GameStream client cert fingerprint feeds the same map, so a
Moonlight device also keeps its scaling — today `set_client_identity` is only wired on the
punktfunk/1 path.

**Carriers per backend:**

- **Windows** — shipped: slot → EDID serial + IddCx ConnectorIndex; Windows keys
  `PerMonitorSettings` (DPI scaling) on exactly that. Cap 15 (ConnectorIndex <
  MaxMonitorsSupported=16). `per-client-mode` and per-display ordinals work unchanged but burn
  slots faster — the LRU already handles pressure; document the trade-off.
- **KWin** — the carrier is the **output name**: `stream_virtual_output(name, …)` becomes
  `punktfunk-<slot>` → output `Virtual-punktfunk-<slot>`. KWin persists per-output config
  (scale, transform, mode) in `kwinoutputconfig.json`, matching EDID-less outputs **by name** —
  so a stable per-client name is precisely what makes KDE reapply that client's scaling.
  Two validation items before relying on it (Stage 3 gate, §11):
  1. confirm KWin ≥ 6.5.6 actually persists + reapplies scale for `Virtual-*` outputs;
  2. confirm a *remembered mode* doesn't fight the freshly requested one (if KWin reapplies a
     stale stored mode on output-added, our existing `set_custom_refresh`/mode apply must run
     after and win — it already reads back the achieved mode, so a fight is at least visible).
  Side effect worth having: distinct names also unclash concurrent sessions (today two
  simultaneous KWin sessions both create `Virtual-punktfunk` and `set_custom_refresh` /
  `other_enabled_outputs` match **by that shared name** — a latent multi-view bug this fixes).
- **wlroots** — no rename and no settable description via IPC; headless outputs are
  `HEADLESS-N` by creation order. Identity is therefore **not reliably carriable** → declared
  unsupported (`shared` behavior regardless of setting; capability matrix + docs say so). The
  single-session case is de-facto stable (`HEADLESS-1`), which users can pin in sway config —
  document that recipe instead of pretending.
- **Mutter** — `RecordVirtual` auto-generates the virtual monitor's serial; no public D-Bus
  surface to control it → unsupported for now. Note for later: re-evaluate Mutter's
  virtual-monitor D-Bus surface per GNOME release (tracked as an open item, not a promise).
- **gamescope** — n/a: the client streams a whole nested session; scaling inside it is per-game.

**Scale as a punktfunk-side option (small, high-value adjunct):** KWin's
`stream_virtual_output` takes a `scale` argument we currently hardcode to `1.0`. Add an optional
per-client `default_scale` (console-editable next to the device list) passed at create on KWin;
on Windows scaling stays the OS's job (identity makes it persist). This gives HiDPI phones/
tablets a correct-sized desktop on first connect, before any DE-side persistence exists. A
client-requested scale hint in the Hello (trailing-byte back-compat, like the gamepad-pref byte)
is future protocol growth — design it when a client actually wants to send it.

## 6. Multi-monitor

Two scenarios, deliberately separated because they differ ~10× in cost:

- **§6A — many clients, one desktop ("second screen")**: each client device becomes one more
  monitor of the same host desktop (tablet as a side monitor next to the laptop's stream).
  Structurally this already half-exists on the Linux desktop compositors (`separate` gives
  every client its own output on the shared desktop); what's missing is *intent*: layout
  control, group-aware topology, and honest per-backend gating. **No protocol change** — it
  ships on the registry work.
- **§6B — one client, many displays**: a client with two physical monitors gets two virtual
  displays, streamed as two video planes, presented one-per-monitor, arranged on the host to
  mirror the client's physical arrangement. Needs protocol growth, N encoder pipelines, client
  presenter work, and (on Windows) the multi-monitor manager. **punktfunk/1-native only** —
  GameStream/Moonlight has no multi-display vocabulary and stays single-stream.

### 6.1 Display groups (registry concept, serves both)

`ManagedDisplay` slots gain a **group**: the set of displays sharing one desktop/session.

- kwin / mutter / wlroots: one group per compositor session — every acquired slot joins it
  (that *is* the shared desktop).
- gamescope spawn: one group per spawned nested session. gamescope is single-output — a §6B
  client asking N displays there resolves to 1, honestly (the extra `AddDisplay`s are declined).
- Windows: one group (the desktop); slots = IddCx monitors (§6.6).

Group-aware semantics — these fix latent issues even before multi-monitor ships:

- **`exclusive` disables only non-managed (physical/bootstrap) outputs, never group members.**
  Today's KWin `apply_virtual_primary` disables "everything not named `Virtual-punktfunk`" —
  under Stage-3 per-slot names, a second session's exclusive would disable the *first* session's
  live output. The filter must consult the registry (the set of managed output names), not one
  hardcoded name. Same shape on Windows (`isolate_displays_ccd` isolates to the managed target
  *set*) and Mutter (the sole-monitor config includes all group members).
- **`primary` designates one group member** — for §6B the client marks which of its displays is
  primary (its OS already knows); for §6A the first slot wins unless the console re-designates.
- **Topology restore is per-group, not per-display** — the saved pre-stream config is restored
  when the group's **last** member drops, never while siblings live. (Windows `SavedConfig` and
  the KWin `restore` vec move from `Monitor`/`StopGuard` into the group record.)

### 6.2 Layout

The `layout` policy block (§4.1) controls where group members sit in the desktop space:

- `auto-row` (default): left-to-right in acquire order, top-aligned — what compositors mostly
  do anyway, made deterministic.
- `manual`: per-identity-slot offsets, console-edited (an OS-settings-style drag mini-map is
  the stretch UI; an x/y table ships first). Keyed by identity slot, so *client B's tablet
  always reappears to the right of client A's monitor* — layout + identity compose.
- A §6B client sends its real monitor arrangement as per-display position hints; they override
  `auto-row` (mouse crossing between streamed monitors then matches the client's physical
  layout) but lose to `manual` pins.

Backend mapping — all existing tooling, no new protocols: KWin
`kscreen-doctor output.X.position.x,y` (validate syntax the way `set_custom_refresh` did);
wlroots `swaymsg output <n> position X Y`; Mutter logical-monitor positions in the same
`ApplyMonitorsConfig` we already build; Windows CCD source origins in the same
`SetDisplayConfig` path `isolate_displays_ccd` uses.

**Host-side input routing.** §6A needs nothing (N clients inject into one desktop — already
true today). §6B needs the injectors to map `(display, x, y)` → desktop coordinates using the
group layout: per-backend work items — libei absolute positioning is per-region, the wlr
virtual-pointer protocol binds to an output, Windows `SendInput` absolute is desktop-normalized
(pure math off the group layout). Wire change in §6.3.

Two realities to document, not engineer around: **cursor rendering is already correct** (every
backend embeds the cursor per-output — KWin `POINTER_EMBEDDED`, the IDD's per-monitor
composition — so it appears only on the stream it's on and "crosses" between monitors
naturally), and **a §6A desktop has one cursor shared by all member clients** — exactly right
for the one-user-two-devices case (touch the tablet, the cursor jumps there), chaotic for two
people; genuinely independent users want gamescope multi-user
(`design/gamescope-multiuser.md`), not groups.

### 6.3 Protocol growth for §6B (punktfunk/1 only)

Principle: **a display is one data-plane instance.** Don't touch the hardened core packet
format — N displays = N × (encoder + send thread + core `Session` over its own UDP flow), one
shared QUIC control connection, one set of session-scoped side planes (audio, mic, rumble,
input). And **don't grow the Hello**: the handshake's back-compat idiom is single trailing
bytes — a variable-length display list doesn't fit it, and it doesn't need to, because the
control stream stays open after `Start` (Reconfigure/ClockProbe already ride it).

- **Capability**: client advertises `VIDEO_CAP_MULTI_DISPLAY` (`video_caps` bit `0x10`); the
  Welcome echoes the host's per-session display budget as one trailing byte (`max_displays`
  remaining, `0`/absent = single-display host — old hosts are automatically honest).
- **Negotiation**: the Hello/Welcome pair is untouched and establishes **display 0** exactly as
  today (an old host serves a multi-monitor-capable client's primary display with zero special
  cases). Extra displays negotiate post-`Start` on the control stream:
  `AddDisplay { mode, position_hint, primary: bool } → DisplayAdded { index, config /* the same
  honest per-display Config shape the Welcome carries: mode, bit depth, chroma, codec */ }` or
  `DisplayDeclined { reason }`. `RemoveDisplay { index }` and a per-display `Reconfigure`
  (index as a trailing byte on the existing message) complete the set — **client monitor
  hotplug maps 1:1 onto Add/Remove mid-session.**
- **Data plane**: `DisplayAdded` carries the flow binding (host UDP port / flow token) for that
  display's own core `Session`. Per-flow crypto derives the AES-GCM nonce salts per
  (direction, display index) — no salt reuse across flows; FEC domains are independent per flow
  (loss on one display can't stall another) — this is why "one Session per display" beats
  muxing display ids into the core packet format.
- **Side planes**: pointer/touch events gain a display-index byte (same trailing-byte pattern
  as the gamepad pref; absent = display 0); 0xCF host-timing and 0xCE HDR-metadata datagrams
  gain the index the same way (a client mixing an HDR laptop panel + SDR external monitor gets
  per-display grades). Audio/mic/rumble/gamepad stay session-scoped, untouched.
- **Per-display honesty**: each display negotiates bit depth/chroma/codec independently through
  the same resolve functions — a host that can afford HEVC Main10 on one head and only 4:2:0 on
  the second says so in each `DisplayAdded.config`.
- **Stats**: the stats-unification vocabulary (four measurement points, p50/p95 windows) gains
  a display dimension — per-display series, HUD shows the focused display's equation
  (`design/stats-unification.md` gets a §6B addendum; don't invent client-local stats).
- **C ABI / connector**: `punktfunk_add_display` / per-display `next_au` routing (an index out
  param on the existing call keeps the ABI additive), so PunktfunkKit/JNI stay on the shared
  connector.

### 6.4 Encoder & resource budget

N displays = N encode pipelines. NVENC consumer session caps — and the existing auto 2-way
**split-encode** above ~1 Gpix/s consuming *two* NVENC sessions for one stream — mean admission
must budget: `DisplayAdded` is granted only if the encoder backend confirms capacity (extend the
existing NVENC session accounting + the AMF/QSV probes with a `can_open_another()` check), and
**split-encode is disabled for multi-display sessions** (displays win over split; a 5K@240
single head is not the multi-monitor use case). `max_displays` bounds the group. Same idle-cost
note as keep-alive: every added display composites + encodes at full rate. Bandwidth is
per-display additive (two 4K heads ≈ 2× the bitrate): the per-host speed test's recommendation
should be read **per session** and split across that session's displays — the client divides
its ask, the host doesn't second-guess it (per-display bitrate is deliberately not host policy,
§4.1).

### 6.5 Client staging for §6B

- **Linux GTK + Windows clients first** — natural multi-window presenters: one
  window/fullscreen surface per display on the matching physical monitor, the existing capture
  state machine extended to span them (pointer crossing between our fullscreen windows must not
  release capture).
- **macOS second** (multi-NSWindow across Screens; Spaces/fullscreen interplay is the risk).
- **Android/iOS/tvOS: never advertise the capability** — single-display presenters. A phone or
  tablet still participates in multi-monitor via §6A (it *is* a second monitor), which needs
  nothing from those clients.

### 6.6 Windows multi-monitor manager

Previously an explicit non-goal; now a designed **final stage** — the single-monitor manager
keeps working unchanged until it lands:

- **Manager**: the singleton's `MgrState` becomes a map keyed by connector id; `lifecycle.rs`
  is already written per-slot, so the Windows manager's delegation doesn't change shape. The
  IDD reconnect preempts (dead-swapchain, WUDFHost-death) become per-slot.
- **Driver**: pf-vdisplay already ADDs by connector id 1..=15 (the identity map's bound). The
  sealed frame channel (`IOCTL_SET_FRAME_CHANNEL`) must become **per-monitor** — channel
  messages carry the monitor id, reusing the multi-pad `pad_index` pattern (driver proto v3;
  `design/idd-push-security.md` addendum: same unnamed-object + handle-dup broker per ring).
  Driver work + CI + on-glass validation is exactly why this stage is last.
- **Capture/encode**: one IDD-push capturer per monitor ring; budget per §6.4.
- **CCD**: isolate/primary/layout already group-aware from §6.1/6.2.

## 7. Per-backend capability matrix

What each backend supports; unsupported cells resolve to the stated fallback and are surfaced in
`GET /api/v1/display/state` per display (`"capabilities": [...]`) so the console can grey options
out per-host instead of lying:

| Capability | KWin | gamescope spawn | gamescope managed | gamescope attach | Mutter | wlroots | Windows |
|---|---|---|---|---|---|---|---|
| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped incl. `Pinned` (forever) |
| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output <n> mode --custom` | ✅ `reconfigure()` shipped |
| topology: primary | ✅ | n/a | n/a | n/a | ✅ | ❌ → extend | ✅ (new, small) |
| topology: exclusive | ✅ shipped (filter → group-aware) | n/a | n/a | n/a | ✅ shipped (→ group-aware) | ✅ (new, small) | ✅ shipped (→ group-aware) |
| mode_conflict: separate / §6A group | ✅ multi-output | ✅ one gamescope per client (independent sessions, no shared desktop) | ❌ single session → steal/join/reject only | — | ✅ assumed — **validate ≥2 RecordVirtual monitors** | ✅ HEADLESS-N | ⏳ §6.6 (until then → join + warning) |
| §6B multi-display for one client | ✅ N outputs + layout | ❌ single-output (extra displays declined) | ❌ | — | ⚠ gated on the ≥2-monitor validation | ✅ | ⏳ §6.6 |
| layout (position control) | ✅ kscreen position | n/a | n/a | n/a | ✅ ApplyMonitorsConfig | ✅ `output position` | ✅ CCD origins |
| stable identity | ✅ output name per slot | n/a | n/a | n/a | ❌ (API gives no serial control) | ❌ (no name control) | ✅ shipped |

The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the
registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity,
conflict = join-only. That's just codifying reality. **Gap (2026-07-05):** the shipped registry
does NOT implement this row — it pools every `remote_fd == None` output, including the
managed/attach/SteamOS paths' unit-keepalive outputs, which double-owns the managed session
against the gamescope module's own restore worker (stale-node reuse wedge on game-mode
reconnect). The fix — explicit display **ownership classes** + registry-owned managed-session
restore — is designed in `design/gamemode-and-dedicated-sessions.md` Part A.

## 8. Management API, web console, tray

Endpoints (bearer-only, like `/gpus`; documented in `mgmt.rs`'s OpenAPI → regenerate
`api/openapi.json`):

- `GET /api/v1/display/settings` → `{ settings, preset_expansions, capabilities }` — the stored
  policy plus what this host's live backend can actually do (so the console renders accurate
  controls).
- `PUT /api/v1/display/settings` — validate (unknown fields rejected, ranges clamped like the
  GPU PUT), persist atomically, log. Applies from the next acquire/release.
- `GET /api/v1/display/state` → live slots:
  ```json
  { "displays": [ { "slot": 3, "backend": "kwin", "output": "Virtual-punktfunk-3",
      "mode": "2560x1440@120", "state": "lingering", "expires_in_s": 240,
      "client": "a1b2c3…(label)", "display_index": 0, "sessions": 0,
      "group": 1, "position": {"x": 0, "y": 0}, "topology": "exclusive" } ] }
  ```
- `POST /api/v1/display/release` `{ "slot": 3 }` or `{}` (all) — immediately tear down
  Lingering/Pinned displays. **Refuses Active** (stopping a live session is session management,
  not display management — don't blur it).
- `PUT /api/v1/display/layout` `{ "positions": { "<slot>": {"x":…, "y":…} } }` — the manual
  arrangement (applies live to affected groups; persisted into the policy's layout block).

Web console (Host page, next to the GPU card): a **Virtual displays** card — preset selector
(radio + one-line story each, `custom` unlocking the advanced fields), the live display list from
`/state` with per-row "Release" buttons and a linger countdown, the arrangement editor (x/y
table first, drag mini-map stretch), capability-aware disabled states. The loopback
`local/summary` gains a `displays_live` count (counts only — the established no-secrets rule) so
the **tray** tooltip can show "1 display kept alive" and offer a release-all action through the
same elevation path as start/stop (Windows) / `systemctl --user` (Linux) — tray work is a
stretch stage, not core.

## 9. Enforcement points (exact code paths)

1. **punktfunk/1 handshake** (`punktfunk1.rs`, where the Hello is resolved into the Welcome):
   call `registry::admit(identity, requested_mode)` → on `Reject` answer the typed refusal; on
   `Join` the Welcome's `Config` carries the live mode; on `Steal` signal victims + wait release
   (bounded) before proceeding. This runs **before** `SessionContext` is built.
2. **`virtual_stream` / `build_pipeline`** (`punktfunk1.rs:3511`, `build_pipeline_with_retry`):
   `vd.create(mode)` → `registry::acquire(...) -> (DisplayLease, CaptureSource)`; the retry-hold
   lease keeps its exact semantics. The mid-stream **Reconfigure**, **session-switch**, and
   **capture-loss rebuild** paths re-acquire through the registry so a compositor switch
   correctly releases the old backend's slot and the new mode updates the slot's record.
3. **Control stream, post-Start** (§6B): `AddDisplay`/`RemoveDisplay` handlers spawn/stop a
   per-display pipeline (its own `registry::acquire`, encoder, send thread, UDP flow) inside the
   same `SessionContext` lifetime; `--max-concurrent` counts sessions, not displays.
4. **GameStream** (`gamestream/stream.rs::open_gs_virtual_source`): same acquire; identity from
   the paired client cert fp (new); quit-app → `release(quit=true)` which bypasses keep-alive.
5. **Session end**: capturer drop (releases the PipeWire consumer / ring) then `DisplayLease`
   drop → lifecycle decides Linger/Pinned/teardown. On Linux the keepalive no longer rides the
   capturer (§3 ownership split).
6. **`serve` startup/shutdown**: registry constructed once (like `start_restore_worker`), all
   slots torn down on graceful exit.

## 10. Documentation plan

A dedicated docs-site page **`docs-site/content/docs/virtual-displays.md`** (+ `meta.json`
entry), cross-linked from `configuration.md`, `host-cli.md`, `steamos-host.md`, and
`troubleshooting.md`. Structure — written for the operator, presets first:

1. **What punktfunk does with displays** — 5 lines: per-client-sized virtual output, created on
   connect, what "keep alive"/"exclusive" mean physically.
2. **Pick a preset** — the §4.3 table verbatim, each with a one-paragraph story and the JSON it
   expands to ("copy this into display-settings.json, or click it in the console").
3. **Options reference** — one subsection per option: values, default, per-backend support
   badge row, and a concrete example scenario each ("You stream from your phone at 1080p and
   your TV at 4K120: with `identity: per-client` KDE remembers 150 % scaling for the phone and
   100 % for the TV").
4. **Multi-monitor** — the two scenarios in user language: *"use your tablet as a second
   monitor"* (§6A: connect a second device, arrange it in the console) and *"stream your
   dual-monitor setup"* (§6B: which clients support it, what the host does with the layout),
   plus the support matrix and the GameStream single-stream note.
5. **Persistent scaling (KDE/Windows)** — the user-visible recipe: connect once, set scaling in
   System Settings / Windows Settings while streaming, done — punktfunk's stable identity makes
   the DE reapply it. Honest support table (KWin ✅ / Windows ✅ / GNOME ❌ why / Sway recipe).
6. **Troubleshooting** — "my physical monitors stayed off" → release button/endpoint + the
   keep_alive×exclusive explanation; "second client gets the wrong resolution" → `join`
   semantics; "game restarted on reconnect" → gamescope reconfigure caveat; "second display
   declined" → encoder budget (§6.4); KWin/gamescope version floors.
7. **Legacy env knobs** — the §4.2 mapping table, marked deprecated.

Also update: `README.md` status row, `CLAUDE.md` (status + invariant below), `host.env.example`
(point at the JSON/console, list deprecated knobs), and the OpenAPI snapshot.

**New design invariant for CLAUDE.md** (once shipped): *Display lifecycle is owned by the
registry, policy-driven; sessions hold leases, never the keepalive. New backends implement
`VirtualDisplay` + declare capabilities; they never grow their own lifecycle/env knobs. A
display is one data-plane instance — multi-display never muxes into the core packet format.*

## 11. Staged implementation

Each stage lands green (`cargo test/clippy/fmt`, OpenAPI drift check) and is independently
shippable; on-glass validation notes inline. **Heads-up for this box:** the dev VM currently has
no GPU passthrough (RTX 5070 Ti detached at the Proxmox level, 2026-07-01) — KWin-path live
validation needs the GPU back or one of the LAN hosts.

### Status — 2026-07-05 handoff

Branch **`display-mgmt-stage0`** (NOT merged; merge when the whole feature is polished/complete).
On-glass validation boxes: **`.173`** (Windows, pf-vdisplay + a physical monitor), **`.21`** (CachyOS
GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `fedora:43` distrobox;
`.48` Fedora KDE is DOWN). Every commit is `cargo test/clippy/fmt`-green.

- **Stages 0–4: DONE + on-glass validated.** 0 (policy surface + `/display/settings` + console card),
  1 (pure `lifecycle.rs` + `registry.rs` Linux keep-alive pool + ownership split via `DisplayLease` +
  `/display/state`/`/display/release`), 2 (topology decoupling — distinct `extend`/`primary`/`exclusive`
  via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
  output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
  4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
- **Stage 5 (§6A): DONE + on-glass validated (KWin `.116` + Mutter `.21`).** All §6A group semantics
  landed + unit-tested, then live-validated (group model, positions, identity keying, group-aware
  exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors; the dev VM itself is GPU-less):
  **display groups** (`registry::group_key` — one
  per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin
  `MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than
  re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via
  `take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling
  and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure
  **layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`**
  (`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the
  `PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now
  carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement
  on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). The **web arrangement table**
  (`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done, moved to its own **Virtual displays** nav
  section with a full one-click-preset config surface. **Remaining = hardware-gated residuals only:** the
  per-group physical-restore EFFECT (needs a monitor-attached box — the headless boxes report
  `also_disabled=[]`), wlroots `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert —
  see the Stage 5 entry below. Plus the **keep-alive reconnect hardening** (`b53710d`, on-glass validated):
  same-client zombie preempt + deliberate-quit skip-linger + tunable idle timeout (§5.1) — what made
  "reconnect resumes" actually hold under a fast reconnect.

**Decisions / deltas from this plan as written — read before continuing:**
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
  concurrent Windows sessions both drive one pf-vdisplay monitor's **single-capturer** IDD-push channel
  (newest-delivery-wins) → the 2nd freezes + can WEDGE the 1st (observed live: it wedged `.173`, needed a
  reboot — surfaced as Moonlight "no video"). True multi-session Windows capture is §6.6/Stage 7. So on
  Windows `separate` (incl. the unconfigured default) resolves to `reject` — a 2nd client gets a clean
  503, the live session is protected; `join`/`steal` are explicit opt-ins. Centralised in
  `admission::effective_conflict()`, shared by the native handshake + GameStream `h_launch`.
- **Reject IS typed:** punktfunk/1 closes the QUIC connection with app code `0x42` + the reason
  `"host busy: streaming WxH@Hz to <client>"`, which the client reads from `ApplicationClosed`.
- **Stage 5's group-aware exclusive fixes a bug Stage 3 introduced:** per-slot names meant a 2nd
  `exclusive` session's disable-filter would black out the 1st session's `Virtual-punktfunk-<id>` output.
  Fixed on KWin by recognising the whole managed group via the shared `Virtual-punktfunk` prefix.
- **GameStream 503** is implemented (owner-fp on `LaunchSession`, `gamestream_admission()` unit-tested,
  shares `effective_conflict()`) but NOT Moonlight-validated (can't drive `/launch` autonomously).

**Validated since (2026-07-05):** the KWin **set-scaling ROUND-TRIP** — a client set 150 % then 125 %
in the streamed KDE session, disconnected, reconnected, and the scale was reapplied to the freshly
re-created `Virtual-punktfunk-<id>` (proven in `kwinoutputconfig.json`); this closes the Stage-3 gate.
Also the §6A group model + group-aware exclusive/extend + 2 concurrent Mutter `RecordVirtual` monitors,
and the keep-alive reconnect hardening.

**Still deferred (need a display-attached box / a specific compositor / a real client):** the `primary`
physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; GameStream
503 on-glass; and the **per-group physical-restore EFFECT** — a monitor-attached box is required to see
`exclusive` disable a physical output and the group restore re-enable it only after the last member drops
(the headless boxes report `also_disabled=[]`, so the group semantics are proven but the physical
toggle isn't).

- **Stage 0 — policy + plumbing-lite. [DONE ✓]** `policy.rs` (schema/presets/persist/env-compat, fully
  unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page
  skeleton with the presets/options tables. Behavior deltas limited to what existing knobs can
  express: Windows linger reads the policy; Linux topology auto/extend/exclusive routes through
  the existing primary code. *No lifecycle change yet — zero-risk adoption of the surface.*
- **Stage 1 — lifecycle core + Linux keep-alive. [DONE ✓]** `lifecycle.rs` pure machine
  (+proptests: no lost teardowns, no double-frees across arbitrary acquire/release/expiry
  interleavings), `registry.rs`, the ownership split (`DisplayLease`/`CaptureSource` — the one
  cross-cutting refactor, touches `capture_virtual_output` signatures on both OSes), keep-alive
  live for **wlroots** and **gamescope-spawn** (the two backends where reuse is structurally
  trivial), `/display/state` + `/display/release`, console live-list. Windows manager delegates
  linger/pinned decisions to `lifecycle.rs` (its driver specifics untouched).
  *Validate:* sway on this box (headless), gamescope spawn: connect → disconnect → verify
  vkcube/game still runs → reconnect → same session, no relaunch.
- **Stage 2 — topology decoupling. [DONE ✓]** Kept-node PipeWire re-attach on
  KWin and Mutter (each behind its validation; fallback recreate), `primary` (without disable)
  on KWin/Mutter/Windows, `exclusive` on wlroots, restore paths regression-tested.
  *Validate:* headless KDE session (the `run-headless-kde.sh` rig), GNOME box .248.
- **Stage 3 — identity. [DONE ✓]** Platform-neutral identity map + migration, per-slot KWin output
  naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring,
  optional `per-client-mode` keying, per-client `default_scale` on KWin.
  *Validated on KDE (`.116`, 2026-07-05):* a client set 150 % then 125 % in the streamed session,
  disconnected, reconnected (keep-alive off → full teardown+recreate), and the scale was reapplied to
  the fresh `Virtual-punktfunk-<id>` — confirmed in `kwinoutputconfig.json` (`scale=1.25` persisted by
  connector name). This is the round-trip the persist mechanism was designed for. *(client-B-unaffected
  under two concurrent sessions is folded into the Stage-5 two-session case.)*
- **Stage 4 — mode-conflict admission. [DONE ✓]** Decision function (`vdisplay/admission.rs`,
  `decide`/`admit`/`effective_conflict`) wired into the punktfunk/1 handshake + GameStream `h_launch`,
  the typed punktfunk/1 `busy` refusal (QUIC close `0x42` + reason), GameStream 503 path, `steal`
  victim signaling reusing the stop-flag plumbing. **The Windows default is `reject`, NOT the
  `join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer
  IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
  unit-tested, Moonlight-pending.
- **Stage 5 — §6A multi-client monitors. [DONE ✓ — on-glass validated (KWin `.116` + Mutter `.21`); hardware-gated residuals deferred]** Display
  groups, group-aware exclusive/primary/restore (incl. the name-filter fix), layout auto-row + manual,
  `/display/layout`, console arrangement table. Cheap: rides Stages 1–3 infrastructure, no protocol change.
  **Done:**
  - KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
    `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
    unit-tested.
  - **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order,
    top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members),
    unit-tested. `manual_position` helper for a single backend-local apply.
  - **Registry group model** (Linux): group = backend (one desktop per compositor session); the
    `/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's
    `position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` /
    `identity_slot` / `topology`. The backend reports its resolved slot via the new
    `VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity.
  - **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by
    identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current
    effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to
    the other axes). OpenAPI regenerated.
  - **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin
    implements it via `kscreen-doctor output.<n>.position.<x>,<y>`): the registry owns the group, so
    right after `create` it computes the new display's position over the whole group via the pure
    `position_for_new` (existing same-backend members in acquire order + the new one appended last →
    `layout::arrange` → the new member's placement) and calls `apply_position`. This makes **both**
    auto-row (deterministic left-to-right, not just the compositor's default) **and** manual placement
    go through one seam. Guarded: the registry skips the desktop origin `(0, 0)`, so a single-display /
    first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no
    positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new`
    is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).*
  - **Per-group topology restore** (design §6.1 — restore the physical only when the group's LAST member
    drops): the KWin `exclusive` restore no longer rides the per-session `StopGuard` (which would re-enable
    the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin
    now hands the restore to the registry as a closure (`take_topology_restore`); the registry keeps it in
    the display **group** (`Entry::topology_restore`) and, on teardown, **floats** it to a surviving
    same-group sibling (`hand_off_restore`) or, when the group empties, runs it — outside the lock, BEFORE
    the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths
    (lease drop / linger expiry / mgmt release) honor it. The single-display path is byte-for-byte
    unchanged (one member → run on its teardown). `hand_off_restore` is unit-tested (float / run-on-last /
    non-carrier-first / never-cross-backend). *Residual concurrent-connect race + two-session on-glass
    validation pending.*
  - **Mutter group-aware** (`set_first_in_group`): the registry tells each backend whether it is the
    FIRST display of its group; a non-first Mutter session **extends** into the already-exclusive desktop
    instead of re-applying a sole-monitor `ApplyMonitorsConfig` that would disable the first session's
    virtual. (Simpler than the originally-planned "include all group virtuals," which Mutter can't do —
    its connectors are un-nameable — and achieves the same connect-time outcome.) Single-session unchanged
    (`first == true`). *Residual: Mutter `APPLY_TEMPORARY` reverts the topology when the FIRST session
    leaves under a live sibling (§7) — a full fix needs a group-owned `DisplayConfig` connection; deferred.
    Concurrent-Mutter on-glass validation pending (even ≥2 `RecordVirtual` monitors is unproven).*
  - **gamescope groups** (design §6.1): a gamescope **spawn** is an independent nested session per client
    (no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never
    auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested.
    (§6B single-output "decline extras" is Stage 6.)
  - **Console arrangement table (web)** [DONE ✓]: a `DisplayArrangement` x/y editor in the `Virtual
    displays` card (`web/src/sections/Host/DisplayCard.tsx`) — for a ≥2-display group it renders an x/y
    table over the live displays that carry an identity slot, seeded from `/display/state`, and Save
    writes `PUT /display/layout` (switches the host to a manual layout, applied next connect). en+de
    i18n; the stale `display_pending_note` copy refreshed. tsc + vite build green. (Drag mini-map is a
    later stretch.)
  **Remaining Stage 5 — hardware-gated residuals only (no more host/web build work):**
  - **On-glass validation — mostly DONE (2026-07-05, KWin `.116` + Mutter `.21`):** the group model,
    per-member positions, identity keying, group-aware `exclusive`/`extend` coexistence (a 2nd session
    does NOT clobber the 1st's output), and **2 concurrent Mutter `RecordVirtual` monitors** are all
    confirmed live; the keep-alive reconnect path (reuse, quit-skip-linger, tunable idle) was validated
    deterministically with the probe. **STILL PENDING: the per-group physical-restore EFFECT** — both
    boxes are headless so `also_disabled=[]` (nothing to disable → nothing to restore); seeing
    `exclusive` black out a real monitor and the group restore re-enable it only after the LAST member
    drops needs a **monitor-attached Linux box**. The group-restore LOGIC (`hand_off_restore`) is
    unit-tested; only the physical effect is unobserved.
  - **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs
    a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works
    (independent `HEADLESS-N` outputs).
  - **Mutter `APPLY_TEMPORARY` disconnect-revert** (§7): when the FIRST Mutter session leaves under a live
    sibling, Mutter reverts the topology — a full fix needs a group-owned `DisplayConfig` connection.
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
  stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
  KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window
  presenter, stats display dimension.
  *Validate:* loopback probe requesting 2 displays → two decodable .h265 outs + per-display
  0xCF; then a real dual-monitor Linux client against the KDE box.
- **Stage 7 — Windows multi-monitor** (§6.6: driver proto v3 per-monitor sealed rings, manager
  slot map, Windows client multi-window, `separate` un-gated on Windows) — gated on driver CI +
  on-glass, deliberately last.
- **Stage 8 — polish.** Docs page finalized with real console screenshots, tray count/release
  (stretch), README/CLAUDE.md/host.env.example updates, `local/summary` count, macOS §6B
  presenter (its own mini-stage when scheduled).

## 12. Risks & open questions

- **PipeWire node reuse after consumer detach (KWin/Mutter)** — the load-bearing unknown for
  Stage 2. If a kept node won't renegotiate for a fresh consumer, keep-alive on those backends
  degrades to "topology-stable but recreate-on-reconnect" (still valuable: no desktop reshuffle
  when *paired with identity naming*). The fallback is designed in, so the stage can't strand.
- **KWin persistence of `Virtual-*` output config** — if KWin declines to persist virtual
  outputs, per-client scaling on KDE needs punktfunk-side scale storage instead (the
  `default_scale` adjunct already gives us the mechanism); identity naming stays worthwhile for
  the name-clash fix alone.
- **KWin stored-mode vs requested-mode fights** under identity naming (§5.4) — mitigated by
  our post-create mode apply + read-back; watch for it in Stage 3 validation.
- **Compositor ceilings on simultaneous virtual outputs** — load-bearing for §6A/§6B: probe
  KWin's virtual-output count and Mutter's `RecordVirtual` count (≥2 monitors) empirically in
  Stage 2/5; `max_displays` default 4 keeps us under any realistic ceiling.
- **Encoder session exhaustion** (§6.4) — NVENC caps × split-encode × concurrent sessions must
  be budgeted in one place (the admission check), or a second display can silently break an
  unrelated session's encode. Split-encode is disabled for multi-display sessions by design.
- **Per-display input mapping** — each Linux injector (libei, wlr, gamescope EIS) binds
  absolute coordinates differently; the §6B display-index routing is per-injector work with
  per-backend validation, not one generic patch.
- **Client-side multi-window fullscreen juggling** (§6.5) — per-monitor DPI on Windows, Spaces
  on macOS, pointer capture across our own windows; the reason clients stage GTK/Windows first.
- **Idle kept displays burn resources** — a kept gamescope keeps the game rendering (GPU) at
  full rate; a kept KWin output keeps compositing; every §6B display encodes at full rate.
  Document; a later refinement could drop a kept session's refresh, out of scope here.
- **Security posture** — keep-alive keeps a user session composited/running unattended;
  nothing is unlocked that wasn't, and admission still rides pairing. `steal` on `--open`
  hosts is the one sharp edge → docs recommend `reject` there (§5.3). The mgmt endpoints are
  bearer-only; `local/summary` exposes counts only. §6B's extra UDP flows reuse the hardened
  core `Session` unchanged (per-flow salts derived, never reused) — no new crypto surface.
- **Mutter identity** — blocked on GNOME API surface; re-check per GNOME release.
