---
title: "Multi-User / Profiles"
description: "End-to-end design: map a connecting client to a real host OS user account (own auto-logged-in, isolated desktop), configured in the web console; device-assignment + optional per-profile passcode; tvOS bound to the selected Apple TV user."
---

> **Status: design, schema-of-record.** This document is the single source of truth for the
> punktfunk "Magic multi-user / profiles" feature. It **supersedes and absorbs**
> [`design/gamescope-multiuser.md`](gamescope-multiuser.md): the deferred per-uid gamescope isolation
> plumbing is now mandatory here, hardened so its EIS/PipeWire sockets live in the target user's
> `XDG_RUNTIME_DIR` rather than the old shared-`/tmp` sketch. Where any earlier per-section draft
> disagrees with this document, **this document wins** — it has already folded in every blocker and
> load-bearing major from the adversarial review (see the Appendix).

## Table of contents

1. [Overview & goals](#1-overview--goals)
2. [Concepts & terminology](#2-concepts--terminology)
3. [Architecture at a glance](#3-architecture-at-a-glance)
4. [Data model & persistence (`profiles.rs`)](#4-data-model--persistence-profilesrs)
5. [Wire protocol & C ABI](#5-wire-protocol--c-abi)
6. [Security model & threat analysis](#6-security-model--threat-analysis)
7. [Linux host](#7-linux-host)
8. [Windows host](#8-windows-host)
9. [Web console & management API](#9-web-console--management-api)
10. [Apple clients (macOS / iOS / iPadOS)](#10-apple-clients-macos--ios--ipados)
11. [tvOS profile binding](#11-tvos-profile-binding)
12. [Lifecycle & edge cases](#12-lifecycle--edge-cases)
13. [Rollout, phasing, back-compat & testing](#13-rollout-phasing-back-compat--testing)
14. [Open questions](#14-open-questions)
15. [Appendix — reconciliations applied from adversarial review](#15-appendix--reconciliations-applied-from-adversarial-review)

---

## 1. Overview & goals

### 1.1 Roadmap intent

"**Magic multi-user support.**" Today a punktfunk host streams one thing: the host operator's own
graphical session, with one shared input/audio/mic plane. The roadmap promise is that a connecting
client lands in **its human's own desktop, auto-logged-in, isolated**, configured entirely from the
web console — with zero on-box fiddling per connect. A family TV connects and the kid gets the kid's
games; a personal laptop connects and lands straight in the owner's account; a shared Apple TV maps
whoever is in front of it to their profile.

### 1.2 The three locked product decisions

1. **A profile = a real host OS user account.** Not a sandbox, not a virtual persona — a profile
   resolves to a real Linux `uid` / Windows `SID`, and the session runs *as that user* (their `HOME`,
   their Steam library, their files). This is what makes it "their desktop," and it is what forces the
   privilege model in §3/§6/§7.
2. **Device-assignment + an optional per-profile passcode.** A paired device cert can be **assigned**
   to a profile → frictionless connect (the device *is* the authorization). A profile can also carry
   an **optional passcode** (a second factor) so a *shared* device (e.g. a family Apple TV) can pick a
   profile that isn't its assignment.
3. **tvOS is bound to the selected Apple TV user.** One Apple TV = one app identity = one host
   pairing; `TVUserManager.currentUserIdentifier` selects which profile the client auto-picks. That
   identifier is a **client-side hint only** — never a wire trust factor (§6.6, §11).

### 1.3 What ships per platform (the honesty note)

This feature creates OS logon sessions and impersonates OS users; the scope it *actually* delivers in
v1 differs per platform, and we state it plainly rather than imply parity:

- **Linux** delivers genuine **cold auto-login** on first connect — but into a **gamescope game-mode**
  session (a fresh nested gamescope running the resolved title / Steam Big-Picture as the target uid),
  **not** the user's full KDE/GNOME desktop. The isolating gamescope worker is what makes per-user
  separation safe (SEC-3). A full-desktop-per-user path is a later scope.
- **Windows v1** is **fast-user-switch** only: the target user must have been signed in once
  (operator pre-login / FUS); the host then switches the single interactive console to them. True
  **cold console auto-login needs a custom Credential Provider**, which is **deferred** to a later
  phase. Windows is also **sequential** (one active profile at a time — the single-console constraint),
  whereas Linux is concurrent.
- **GameStream / Moonlight is excluded** from profiles entirely (cert-only identity, single global
  catalog, one global `LaunchSession`); GameStream sessions always run as the operator. This is a
  documented protocol fact, not a bug (§6.7).

---

## 2. Concepts & terminology

One name per concept — used consistently throughout this document and the code.

| Term | Meaning |
|---|---|
| **Profile** | A host-side record (`crates/punktfunk-host/src/profiles.rs`) binding a `display_name`, an `OsAccount`, device assignments, an optional passcode, library curation, and session policy. Identified by a stable `ProfileId`. |
| **`ProfileId`** | 12 lowercase hex characters, minted `hex(sha256("{display_name}:{nanos}")[..6])` (like `library::new_id`), plus the reserved id `"operator"`. `PROFILE_ID_MAX = 64` everywhere (matches the `char id[65]` ABI struct). |
| **`OsAccount`** | The real OS user a profile maps to: the enum `{ Operator, Linux{username,uid?}, Windows{account_name,sid?,credential} }`. |
| **Operator profile** | `OsAccount::Operator` — the host operator's already-running shared graphical session = **today's behavior** (shared desktop, shared input/audio, multi-occupant). The implicit default. |
| **`default_profile_id`** | The operator-designated profile for *paired-but-unassigned* devices. `None`/dangling ⇒ the synthesized implicit operator profile. |
| **Device assignment** | A device cert **fingerprint** (lowercase hex SHA-256) listed in `Profile.assigned_fingerprints`. Assignment is *the* frictionless authorization; a fingerprint is in **at most one** profile. |
| **Passcode** | An optional per-profile secret (Argon2id PHC, host-side verify). The **second factor** that gates *shared-device* access to a profile. Never the OS password. |
| **Isolation** | A non-Operator profile runs on its own per-uid worker with its own injector/audio/mic/compositor — its bytes and input never touch another user's plane (SEC-1/SEC-3). |
| **Occupancy** | Single-session-per-resolved-OS-user. Keyed by the **resolved uid/SID**, not the profile id. Operator profiles are exempt (multi-occupant). |

---

## 3. Architecture at a glance

### 3.1 The connect → resolve → session flow

```
client                         zone 1: punktfunk-host (network)            zone 0/2 (privileged + per-user)
  │  QUIC mTLS + pinned host                                                
  ├─ Hello{…, profile_id?} ───►  paired-fingerprint gate (punktfunk1.rs:535-564)
  ├─ ProfileUnlock{passcode}? ─► resolve_session_profile(fp, id?, passcode?)  ── D4 authority table
  │                              │  Operator → today's shared-desktop pipeline (zone 1, no privilege)
  │                              │  non-Operator → SEC-3 fail-closed gate (D5):
  │                              │     • broker present? else SessionUnavailable{NoBroker}
  │                              │     • occupancy by uid/SID free? else Occupied
  │                              │     • OpenSession(profile_id) ──────────────► root broker (PAM+setuid)
  │                              │                                              spawns per-uid worker
  │  ◄─ ProfileReject{reason}    │  (any deny → typed reject + close; one passcode try per connection)
  │  ◄─ Welcome{resolved_profile}│  echoes the RESOLVED profile id
  └─ stream ◄──── encoded AUs ───┘ ◄──── session_fd (encoded AU/audio/input) ──┘
```

### 3.2 The privilege invariant (the headline)

> **Invariant (D7).** The network-facing process — the one parsing attacker-controlled QUIC and
> linking FFmpeg/CUDA/Vulkan/codec FFI (adversary-exposed) — **MUST NOT hold the privilege to become
> other users.**

A single codec/QUIC RCE in that process must not equal "log into any mapped OS account." The feature
is built so that the privilege to impersonate lives in a **tiny separate component with no untrusted
parser and no network listener.**

### 3.3 How the zones realize it (Linux)

Three trust zones (Linux):

- **Zone 1 — `punktfunk-host serve` (network, non-root).** Terminates QUIC, runs Leopard FEC +
  AES-GCM + UDP `sendmmsg`, serves the mgmt API, **reads** `profiles.json`, verifies the passcode. It
  holds *no* power to become another user. It is the adversary-reachable surface.
- **Zone 0 — `punktfunk-session-broker` (root, NEW).** The only root code. No network listener, no
  untrusted parser — its sole input is a length-prefixed `serde_json` request over a
  `SO_PEERCRED`-restricted `SOCK_SEQPACKET` unix socket. It **trusts only a `profile_id`** (re-reads
  `profiles.json` itself — SEC-2), does the PAM session + `setuid`, spawns the per-uid worker, and
  treats the zone-1 connection as the **session lifeline**. Caps trimmed to
  `CAP_SETUID/SETGID/SETPCAP/KILL` only (D7).
- **Zone 2 — `punktfunk-host session-worker` (target uid, NEW).** Runs **as the profile uid** inside
  its PAM/logind session. The *only* process that ever touches that user's **decoded** desktop bytes,
  raw input, and mic — because those live in the user's `XDG_RUNTIME_DIR` (mode 0700), inaccessible to
  zone 1 by construction. It hands **encoded** AUs + Opus back to zone 1 over an inherited
  socketpair and receives opaque input/mic to inject locally.

### 3.4 Windows: mirror the split (target), with a documented v1 gap

Windows has no second concurrent interactive desktop on a client SKU, and the host already runs as
**LocalSystem**. The **target architecture mirrors the Linux split**: a non-SYSTEM streamer front-door
with `LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a tiny SYSTEM
broker over ALPC/named-pipe with a verified client SID.

**Windows v1 may ship the front-door as SYSTEM** — but MUST then **document explicitly** that it does
**not** satisfy the §3.2 invariant: a host RCE = full box compromise *plus* recovery of every stored
credential. Operators therefore run Windows multi-user **trusted-LAN only**, GameStream-style. This
gap is called out wherever Windows appears (§6.2, §8, §13).

---

## 4. Data model & persistence (`profiles.rs`)

**New module:** `crates/punktfunk-host/src/profiles.rs`. **New host dependency:** `argon2 = "0.5"`
(RustCrypto; pure-Rust, builds on MSVC) added to **`punktfunk-host` only** — never to
`punktfunk-core` (D1: the passcode is verified host-side; no client target needs `argon2`).
`serde`/`serde_json`/`sha2`/`hex`/`utoipa` are already present.

### 4.1 Persistence

Profiles live in a **new** `~/.config/punktfunk/profiles.json` (`%ProgramData%\punktfunk\profiles.json`
on Windows) — *not* an extension of `native_pairing`. It is owner-only, atomic-write, built from the
exact helpers `native_pairing.rs` already uses (`gamestream::config_dir` / `create_private_dir` (0700)
/ `write_secret_file` (0600 unix / SYSTEM-DACL Windows), `gamestream/mod.rs:245-313`), with the temp +
atomic-rename discipline of `native_pairing.rs:116-127`.

```rust
// profiles.rs
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::collections::HashMap;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::library::{CustomEntry, GameEntry};

/// Bump on any breaking field-semantics change; `serde(default)` covers additive growth.
pub const PROFILES_SCHEMA_VERSION: u32 = 1;
/// Reserved id of the synthesized implicit profile = today's shared operator session.
pub const OPERATOR_PROFILE_ID: &str = "operator";
/// Max bytes of a wire/ABI profile id (matches `char id[65]` = 64 + NUL).
pub const PROFILE_ID_MAX: usize = 64;

#[derive(Default, Serialize, Deserialize)]
pub struct ProfilesFile {
    /// `0`/absent on a pre-feature/hand-written file is treated as `1`.
    #[serde(default)]
    pub version: u32,
    #[serde(default)]
    pub profiles: Vec<Profile>,
    /// Operator-designated default for paired-but-unassigned devices. `None` (or a dangling id)
    /// => the implicit operator profile. Never set to `OPERATOR_PROFILE_ID` (that's implicit).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_profile_id: Option<String>,
}

fn default_path() -> Result<PathBuf> {
    Ok(crate::gamestream::config_dir().join("profiles.json"))
}
```

**A parse failure is never silently dropped.** Unlike the cert store (which `unwrap_or_default`s), a
malformed `profiles.json` keeps the **raw bytes** in memory, logs loudly, and **refuses mutations**
until readable — a later `save()` must never overwrite a file we failed to parse (that would strand
every assigned device). **A failed `save()` is a failed mutation (D11):** do not keep the optimistic
in-memory change; return an error to the operator. `profiles.json` is the single on-disk source of
truth that both zone 1 and the root broker read.

### 4.2 The `Profile` entity (canonical serde — D3)

```rust
/// Host-assigned, stable id (the `{id}` in CRUD paths). 12 hex chars, minted like
/// `library::new_id` (`library.rs:1239-1246`): `hex(sha256("{display_name}:{nanos}")[..6])`.
pub type ProfileId = String;

#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Profile {
    pub id: ProfileId,
    pub display_name: String,
    /// UI accent ("#RRGGBB" or a brand token). Optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub accent: Option<String>,
    /// Avatar URL or `data:` URL (same "field is a URL the client fetches" convention as
    /// `library::Artwork`). Optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub avatar: Option<String>,
    /// The real OS user this profile lands in. Both platform variants compile cross-platform
    /// (like the library providers) so a file authored on one OS round-trips on the other.
    pub os_account: OsAccount,
    /// Device cert fingerprints (lowercase hex SHA-256) assigned to this profile. A fingerprint
    /// is in AT MOST ONE profile (uniqueness enforced on assign + at load).
    #[serde(default)]
    pub assigned_fingerprints: Vec<String>,
    /// Optional secondary unlock. Argon2id PHC. Never serialized to any client-facing DTO.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub passcode: Option<PasscodeHash>,
    /// Turns the device cert into a TRUE first factor: even an assigned device must supply the
    /// passcode. Default false. (Mitigates a stolen device key for high-value profiles — §6.4.)
    #[serde(default)]
    pub require_passcode_even_when_assigned: bool,
    /// Allow a NON-assigned device to enter this passcode-LESS profile (shared-view opt-in).
    /// Default false → a passcode-less profile is reachable only by its assigned devices/default.
    #[serde(default)]
    pub allow_shared_view: bool,
    /// Advisory operator metadata: Apple TV `currentUserIdentifier`s normally seen on this profile.
    /// NEVER a trust input (D8) — purely a console hint / client auto-select aid.
    #[serde(default)]
    pub tvos_user_ids: Vec<String>,
    /// How the global library is curated for this profile.
    #[serde(default)]
    pub library_scope: LibraryScope,
    /// Profile-private custom titles (same shape as `library::CustomEntry`, `library.rs:1172-1181`).
    #[serde(default)]
    pub custom_entries: Vec<CustomEntry>,
    /// Per-profile session policy (intersected with the client's request at connect AND Reconfigure).
    #[serde(default)]
    pub session_defaults: SessionDefaults,
    pub created_unix: u64,
    pub updated_unix: u64,
}

/// The mapping to a real OS user account. Serde-tagged so it's self-describing on disk.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OsAccount {
    /// The host operator's already-running shared graphical session — today's behavior. Implicit
    /// default. Multi-occupant; served on the shared-desktop backends (kwin/mutter/wlroots).
    Operator,
    /// A real Linux user. The broker drops to this uid and runs a per-uid gamescope worker. No
    /// stored password (PAM is passwordless; the broker is root). `uid` resolved at create time.
    Linux {
        username: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        uid: Option<u32>,
    },
    /// A real Windows user. Auto-login into the single interactive console needs a primary token;
    /// `credential` says how to get one. `sid` resolved at create time (stable across renames).
    Windows {
        /// "DOMAIN\\user" or ".\\user" (local).
        account_name: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        sid: Option<String>,
        credential: CredentialRef,
    },
}

/// Where the Windows auto-login secret lives. We NEVER store the plaintext password in
/// profiles.json — only a reference into an OS-protected vault that SYSTEM can read.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum CredentialRef {
    /// No stored secret: the user is already logged in / FUS-reachable. The host only switches the
    /// console. Graceful default; nothing secret in our store.
    None,
    /// A Windows Credential Manager generic-credential target name (CredReadW). Store only the NAME.
    CredentialManager { target: String },
    /// An LSA private-data key ("L$punktfunk_<id>", LsaRetrievePrivateData). Store only the key name.
    LsaSecret { key: String },
    /// A DPAPI-machine-sealed blob on disk (`cred/<profile_id>.bin`), optionally passcode-wrapped.
    /// The default the Windows host writes (§8.6). Store only the relative path.
    DpapiBlob { path: String },
}

/// Argon2id password-hash record. Self-describing PHC string so a params/algo bump needs no schema
/// change. e.g. "$argon2id$v=19$m=19456,t=2,p=1$<salt-b64>$<hash-b64>".
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PasscodeHash { pub phc: String }

/// How the global library is filtered for this profile. Presentation curation, NOT a sandbox (D12).
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum LibraryScope {
    #[default]
    All,
    /// Only these store-qualified ids are visible (e.g. "steam:570", "custom:abc123").
    Allow { ids: Vec<String> },
    /// Full library minus these ids.
    Deny { ids: Vec<String> },
}

/// Per-profile session policy. Each field mirrors an existing Hello field / HostConfig knob so the
/// resolver computes `client_request ∩ profile_policy`. `None` = no profile constraint.
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct SessionDefaults {
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_width: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_height: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_refresh_hz: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_bitrate_kbps: Option<u32>,
    /// Mirrors `Hello::compositor` / `HostConfig.compositor` (`config.rs:71`). "kwin"|"mutter"|...
    /// VALIDATED at create/update: a non-Operator `os_account` may not set a shared-desktop
    /// compositor — the worker always forces gamescope (SEC-3, §6.3), so the web UI + resolver agree
    /// up front instead of discovering it as a connect-time denial.
    #[serde(default, skip_serializing_if = "Option::is_none")] pub compositor: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub gamepad: Option<String>,
}
```

**Credential asymmetry (contract).** Linux drops privilege to the target uid (broker `setuid`); the
broker is already root, so **no password is needed or stored** — `CredentialRef` is **Windows-only**.
Windows must materialize a *primary token* for a non-logged-in user, which requires the password (or
an existing session) — hence `CredentialRef`.

### 4.3 Passcode: Argon2id parameters + verify (D1/D10)

The passcode is a **secondary factor over an already mutually-authenticated, encrypted QUIC channel**
(the device cert is the primary auth; the host fingerprint is pinned). There is no MITM and no
offline-dictionary exposure on the wire, so — unlike the SPAKE2 pairing PIN — the passcode is sent as
plaintext **inside the established control stream** in a dedicated, never-logged `ProfileUnlock`
message (§5.3), verified host-side, and zeroized immediately. Argon2id protects the *at-rest* file if
`profiles.json` leaks.

- **Algorithm:** Argon2id, version 0x13. **Params (unified across the whole design):** `m = 19456`
  KiB, `t = 2`, `p = 1` (OWASP argon2id floor; ~tens of ms — cheap, online-only).
- **Salt:** 16-byte random per hash via `SaltString::generate(&mut OsRng)`, embedded in the PHC string.
- **Decoy verify (no oracle):** when the target profile is missing or has no passcode, run a **dummy
  verify against a fixed decoy PHC** so every `ProfileUnlock` costs ~one Argon2 evaluation regardless
  of outcome (no profile-existence / passcode-set timing oracle).
- **Online rate limit + lockout** — the real defense against weak codes — is the in-memory
  `PasscodeGate`, keyed by **`(profile_id, client_fp)`** (§4.4, D1).

```rust
fn hash_passcode(plain: &str) -> Result<PasscodeHash> {
    use argon2::{Argon2, Algorithm, Version, Params, PasswordHasher};
    use argon2::password_hash::{SaltString, rand_core::OsRng};
    let salt = SaltString::generate(&mut OsRng);
    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::new(19456, 2, 1, None)?);
    Ok(PasscodeHash { phc: argon.hash_password(plain.as_bytes(), &salt)?.to_string() })
}

fn verify_passcode(hash: &PasscodeHash, candidate: &str) -> bool {
    use argon2::{Argon2, PasswordVerifier};
    use argon2::password_hash::PasswordHash;
    match PasswordHash::new(&hash.phc) {
        Ok(parsed) => Argon2::default().verify_password(candidate.as_bytes(), &parsed).is_ok(),
        Err(_) => false, // a corrupt stored hash never authenticates
    }
}
```

**Entropy floor (D10).** A passcode that **wraps a Windows credential** (§8.6) must be **≥6
alphanumeric** — a 4-digit PIN is offline-crackable against the AES-GCM blob in minutes. 4-digit codes
are allowed **only** for non-credential (Linux/Operator) profiles. The mgmt route enforces this
(§9.5).

### 4.4 The `PasscodeGate` (rate limit / lockout)

```rust
const PASSCODE_FAIL_THRESHOLD: u32 = 5;            // failures before hard lockout
const PASSCODE_LOCKOUT_BASE: Duration = Duration::from_secs(2);   // backoff 2s · 2^failures
const PASSCODE_LOCKOUT_CAP:  Duration = Duration::from_secs(60);
const PASSCODE_HARD_LOCKOUT: Duration = Duration::from_secs(900); // 15 min after threshold

/// Keyed by (profile_id, client_fp) — NOT per-profile (that is a cross-device DoS; any one device
/// could hard-lock a profile for everyone). In-memory; mirrors the `Pending` restart-clearing design
/// (native_pairing.rs:54-83). One attempt per connection (D1) — no in-handshake backoff sleep.
struct PasscodeGate { entries: Mutex<HashMap<(ProfileId, String), GateEntry>> }
struct GateEntry { failures: u32, next_allowed: Instant }
```

**Windows persistence (D1).** The Windows host relaunches on every console-session change
(`service.rs:375-392`), which would otherwise reset the budget. On Windows the `PasscodeGate` counters
(and the occupancy registry) are persisted in the SYSTEM broker / a SYSTEM-DACL on-disk file so a
console-switch restart does not reset the attempt budget.

### 4.5 Profile resolution authority (D4 — the one table)

Two indexes from `assigned_fingerprints`: the persisted **forward** list, and an in-memory **reverse**
`HashMap<fp_hex_lowercase, ProfileId>` rebuilt on load + every mutation (a fingerprint maps to at most
one profile; `assign_device` removes it from any prior profile first; load de-dups last-writer-wins).

```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResolveVia { Assigned, Default, Selected }

#[derive(Clone, Debug)]
pub struct ResolvedProfile {
    pub id: ProfileId,
    pub display_name: String,
    pub os_account: OsAccount,
    pub session_defaults: SessionDefaults,
    pub via: ResolveVia,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProfileError {
    NotFound,                            // unknown profile id  → ABI NotFound(-12)
    PasscodeRequired,                    // passcode needed, none/blank supplied → ABI AuthRequired(-11)
    PasscodeIncorrect,                   // passcode wrong → ABI AuthRequired(-11)
    NotPermitted,                        // passcode-less profile, device not assigned, no shared-view → ABI NotFound(-12)
    RateLimited { retry_in_secs: u64 }, // PasscodeGate lockout → ABI AuthRequired(-11)
}
```

`resolve_session_profile(client_fp, requested_id: Option<&str>, passcode: Option<&str>)` →
`Result<ResolvedProfile, ProfileError>`. (The signature takes `requested_id` and `passcode`
**independently** — the wire delivers the id in `Hello` and the passcode in `ProfileUnlock`, and the
*default-with-passcode* row below needs a passcode even when `requested_id == None`.)

| # | Case | Result |
|---|---|---|
| 1 | `requested=None`, device assigned to profile P | **Grant P** (frictionless) — unless `P.require_passcode_even_when_assigned` → require passcode |
| 2 | `requested=None`, unassigned, default has **no** passcode | Grant default (= operator, today's behavior) |
| 3 | `requested=None`, unassigned, **default has a passcode** | **Require passcode** (NO bypass) |
| 4 | `requested=P`, device assigned to P | Grant (or require passcode iff `require_passcode_even_when_assigned`) |
| 5 | `requested=P`, P has passcode, correct | Grant |
| 6 | `requested=P`, P has passcode, wrong/absent | `PasscodeRequired`/`PasscodeIncorrect` |
| 7 | `requested=P`, P has **no** passcode, device **not** assigned | **Deny** `NotPermitted` — unless `P.allow_shared_view = true` |
| 8 | `requested=P` unknown | `NotFound` |

The default-profile passcode is **enforced** (row 3 closes the bypass). The plaintext passcode is
wrapped in a `Zeroizing<String>` at the call boundary and zeroized after `verify_passcode` returns.
The synthesized **implicit operator profile** (`id: "operator"`, `display_name: "Host"`,
`os_account: Operator`, `library_scope: All`, no passcode) is never stored; to give the operator
account a passcode/scope, create a real `os_account: Operator` profile and set it as
`default_profile_id`.

> **Order of evaluation.** `resolve_session_profile` returns the **profile-level** outcome (rows
> above). The downstream **SEC-3 isolation gate**, **occupancy**, and **broker** checks (§6.3, §6.5,
> §7.4) run *after* a successful resolve, in the session gate — they layer `NeedsIsolation`,
> `Occupied`, and `SessionUnavailable{…}` on top. This keeps the data model pure and the security
> gate fail-closed (D5).

### 4.6 Library scoping

`library::all_games()` (`library.rs:1472-1491`) returns the merged global `Vec<GameEntry>`. Profiles
filter that result; they do **not** fork `library.rs`.

```rust
impl Profiles {
    /// Apply allow/deny to `global`, then append the profile's private custom entries
    /// (via `From<CustomEntry> for GameEntry`, library.rs:1193-1203). Sorted by title like all_games().
    pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}
```

Wiring: `GET /api/v1/library` returns `scoped_library(fp, all_games())` for a **streaming-cert**
caller (the `PeerCertFingerprint` arm of `require_auth`, `mgmt.rs:483-489`) and unscoped `all_games()`
for the bearer operator. **Launch resolution uses the same scope** (`library::launch_command` /
`launch_title` resolve the `Hello.launch` id against the *resolved profile's* scoped set), so a
crafted `Hello.launch` cannot bypass a Deny. **`LibraryScope` is presentation curation, not a sandbox
(D12):** the installed titles come from the *target OS user's* own Steam/HOME (the session runs as that
user); real restriction needs OS-level controls in the account.

### 4.7 Module public API

```rust
pub struct Profiles { /* Mutex<ProfilesState{path, file, reverse_index, raw_unparsed}>, PasscodeGate */ }

impl Profiles {
    pub fn load() -> Result<Profiles>;
    pub fn load_with(path: Option<PathBuf>) -> Result<Profiles>;          // tests / Windows override

    // --- admin (bearer; web console) ---
    pub fn list(&self) -> Vec<Profile>;
    pub fn get(&self, id: &str) -> Option<Profile>;
    pub fn create(&self, input: ProfileInput) -> Result<Profile>;
    pub fn update(&self, id: &str, input: ProfileInput) -> Result<Option<Profile>>; // preserves id/passcode/created;
                                                                                    // changing os_account clears credential + resolved uid/SID (D10)
    pub fn delete(&self, id: &str) -> Result<bool>;                       // side effects per §12.2
    pub fn assign_device(&self, id: &str, fp_hex: &str) -> Result<bool>; // unique: removes fp from any other profile
    pub fn unassign_device(&self, fp_hex: &str) -> Result<bool>;
    pub fn set_passcode(&self, id: &str, passcode: Option<&str>) -> Result<bool>; // None clears; argon2id;
                                                                                  // re-wraps a passcode-wrapped Windows credential (D10)
    pub fn set_default(&self, id: Option<&str>) -> Result<()>;
    pub fn default_profile_id(&self) -> Option<String>;

    // --- runtime (session gate + cert-authed clients) ---
    pub fn resolve_for_device(&self, fp_hex: &str) -> ProfileResolution;  // {Assigned|Default}
    pub fn resolve_session_profile(&self, fp_hex: &str, requested_id: Option<&str>, passcode: Option<&str>)
        -> Result<ResolvedProfile, ProfileError>;
    pub fn enumerate_public(&self, fp_hex: &str) -> Vec<ProfilePublic>;   // SCOPED roster (D9)
    pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}

/// Create/replace body. No `id` (host-owned), no passcode (set via `set_passcode`).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct ProfileInput {
    pub display_name: String,
    #[serde(default)] pub accent: Option<String>,
    #[serde(default)] pub avatar: Option<String>,
    pub os_account: OsAccount,
    #[serde(default)] pub assigned_fingerprints: Vec<String>,
    #[serde(default)] pub require_passcode_even_when_assigned: bool,
    #[serde(default)] pub allow_shared_view: bool,
    #[serde(default)] pub tvos_user_ids: Vec<String>,
    #[serde(default)] pub library_scope: LibraryScope,
    #[serde(default)] pub custom_entries: Vec<CustomEntry>,
    #[serde(default)] pub session_defaults: SessionDefaults,
}
```

`Profiles` is shared exactly like `NativePairing`: an `Arc<Profiles>` added to `MgmtState`
(`mgmt.rs:69,83,117`) **and** threaded into the `punktfunk1` accept loop (`punktfunk1.rs:493`) — the
same `Arc`, one source of truth. It is `Send + Sync` (only `Mutex`-guarded state), so the
`tokio::spawn`-per-session model is unaffected.

### 4.8 Validation at create / update / load (D5/D6/D10)

- **SEC-3 compositor (D5):** a non-`Operator` `os_account` may not set a shared-desktop compositor
  (`kwin`/`mutter`/`wlroots`) in `session_defaults.compositor` — reject at create/update.
- **Occupancy uniqueness (D6):** reject a `profiles.json` (and a create/update) that maps **two
  profiles to the same uid/SID** at load/validate time.
- **Entropy floor (D10):** a passcode wrapping a Windows credential must be ≥6 alphanumeric.
- **Fingerprint uniqueness:** `load()` de-dups the reverse index (last-writer-wins) so a hand-edited
  file can't make resolution ambiguous.

### 4.9 Relationship to the existing `PairedClient` store

Kept **separate**, joined by fingerprint. A device must be **paired** (`punktfunk1-paired.json`,
`native_pairing.rs:22-27`) **and** resolvable to a profile. Pairing answers "may this device connect";
the profile answers "as which OS user, with which library/limits." Order at the session gate
(`punktfunk1.rs:535-564`): (1) `np.is_paired(fp)` (unchanged; unpaired → pending/bail); (2) **NEW:**
`resolve_session_profile`. Cross-store cleanup: `unpair_native_client` (`DELETE /native/clients/{fp}`)
and `np.remove(fp)` also call `profiles.unassign_device(fp)`. A profile may pre-reference an unpaired
fingerprint (operator pre-assigns) — allowed; the pairing gate still governs connect.

---

## 5. Wire protocol & C ABI

**No ABI bump.** All additions use the existing trailing-byte / length-prefixed back-compat convention
and the `connect_exN` chaining pattern. `ABI_VERSION` stays `2` (`lib.rs:52`); the host gate
`hello.abi_version == ABI_VERSION` (`punktfunk1.rs:530`) is unaffected. **GameStream excluded** —
profiles are native `punktfunk/1` only (§6.7).

### 5.1 `Hello` — append `profile_id` after `video_caps`

The Hello carries the **profile id ONLY** (D1). The passcode is **never** in Hello — Hello feeds
logging / pending-knock / approval records (`punktfunk1.rs:548-557`).

```rust
// crates/punktfunk-core/src/quic.rs — Hello (struct :42-81, encode :619-656, decode :658-718)
/// Opaque host ProfileId the client wants this session in (NOT an OS username). Appended after
/// `video_caps` as `len u8 || UTF-8` (≤ PROFILE_ID_MAX). Presence forces the name/launch length
/// bytes + the `video_caps` byte to be emitted as placeholders first so it lands at a deterministic
/// offset (same rule launch/video_caps already obey). None = assigned/default (back-compat).
pub profile_id: Option<String>,
```

`encode` tail (replacing the `need_placeholders`/video_caps tail at `quic.rs:636-654`):

```rust
let need_caps = self.video_caps != 0 || self.profile_id.is_some();
match (&self.name, &self.launch) {
    (None, None) if !need_caps => {}
    (name, _) => { let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX);
        b.push(n.len() as u8); b.extend_from_slice(n.as_bytes()); }
}
if self.launch.is_some() || need_caps {
    let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX);
    b.push(l.len() as u8); b.extend_from_slice(l.as_bytes());
}
if need_caps { b.push(self.video_caps); }
if let Some(p) = &self.profile_id {
    let p = truncate_to(p, PROFILE_ID_MAX); b.push(p.len() as u8); b.extend_from_slice(p.as_bytes());
}
```

`decode` field (after the `video_caps` block at `quic.rs:711-716`):

```rust
profile_id: {
    let name_len = b.get(26).copied().unwrap_or(0) as usize;
    let launch_off = 27 + name_len;
    let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
    let prof_off = launch_off + 1 + launch_len + 1; // caps byte + 1
    b.get(prof_off).and_then(|&len| {
        let len = len as usize;
        if len == 0 || len > PROFILE_ID_MAX { return None; }
        b.get(prof_off+1..prof_off+1+len).and_then(|s| std::str::from_utf8(s).ok()).map(String::from)
    })
},
```

A malformed tail never fails the handshake (the decoder returns `None`). `PROFILE_ID_MAX = 64`.

### 5.2 Control messages (CTL_MAGIC + type-byte framing, like `Reconfigure` `quic.rs:215`)

Type bytes (free range): `MSG_LIST_PROFILES = 0x40`, `MSG_PROFILE_LIST = 0x41`,
`MSG_PROFILE_UNLOCK = 0x42`, `MSG_PROFILE_REJECT = 0x43`. `pub const MAX_PROFILES: usize = 64;`
Each gets `encode()/decode()` mirroring the existing `get_bytes`/`put_bytes` helpers
(`quic.rs:403-419`); decoders validate `CTL_MAGIC` + type byte and exact/`<=` length.

```rust
/// client→host; an alternate first message (pre-connect enumerate). Wire: CTL_MAGIC‖0x40
pub struct ListProfiles;

/// One display-safe profile a paired device may pick. accent/avatar optional (UI only).
pub struct ProfileEntry {
    pub id: String, pub display_name: String,
    pub accent: Option<String>, pub avatar: Option<String>,
    pub passcode_required: bool,
    /// Computed for the REQUESTING cert: true → frictionless, render without a failed attempt (D9).
    pub assigned: bool,
}
/// host→client. Wire 0x41: CTL_MAGIC‖0x41‖u8 count‖[ u8 id_len‖id ‖ u8 name_len‖name
///   ‖ u8 accent_len‖accent ‖ u8 avatar_len‖avatar ‖ u8 flags(bit0=passcode_required,bit1=assigned) ]*
pub struct ProfileList { pub profiles: Vec<ProfileEntry> }

/// client→host. THE never-logged message. Sent immediately after Hello, before the host builds the
/// pipeline, iff the connector has a passcode. The handler MUST NOT place `passcode` in any tracing
/// field. Wire 0x42: CTL_MAGIC‖0x42‖u8 len‖passcode UTF-8 (≤ HELLO_PASSCODE_MAX = 64).
pub struct ProfileUnlock { pub passcode: String }

/// host→client. Terminal typed denial; the host then closes (one passcode try per connection, D1).
pub struct ProfileReject { pub reason: ProfileRejectReason }
pub enum ProfileRejectReason {
    PasscodeRequired, WrongPasscode, LockedOut,     // → ABI AuthRequired(-11)
    NotFound, NotPermitted, NeedsSelection,         // → ABI NotFound(-12)
    Occupied,                                        // → ABI Occupied(-13)
    SessionUnavailable(SessionUnavailableKind),     // → ABI SessionUnavailable(-14)
    NeedsIsolation,                                  // → ABI NeedsIsolation(-15)
}
pub enum SessionUnavailableKind { NoBroker, LoginFailed, NeedsConsoleSwitch, Busy }
```

> **Reconciled out:** the original wire draft's Argon2id-verifier **HMAC challenge-response**
> (`ProfileChallenge`/`ProfileProof`) is **dropped** (D1). It would force `argon2` into
> `punktfunk-core` for every client target (Swift/Kotlin/Rust) for a marginal gain over an
> already-authenticated channel. Only the host depends on `argon2`. There is also **no
> `tvos_attested`** discriminator on any message (D8 — the tvOS identifier is never a wire trust
> signal).

### 5.3 Host `serve_session` ordering (`punktfunk1.rs`)

**First-message dispatch** (`punktfunk1.rs:507`): after the `PairRequest` arm and before
`Hello::decode`, branch on `ListProfiles`:

```rust
if ListProfiles::decode(&first).is_ok() {
    // SCOPED roster (D9): only profiles assigned to THIS cert + passcode-protected picker-visible
    // ones. Unpaired (when require_pairing) → empty (no roster leak).
    let profiles = if peer_is_paired { profiles.enumerate_entries(&fingerprint_hex) } else { vec![] };
    io::write_msg(&mut send, &ProfileList { profiles }.encode()).await?;
    first = read_msg(&mut recv).await?;   // now expect a Hello on the same bi-stream
}
```

**Inside the handshake**, after the `require_pairing` gate (`punktfunk1.rs:564`) and before
`validate_dimensions`:

```rust
// (1) If the connector sent a passcode, it is the NEXT message after Hello (never in Hello).
let passcode: Option<Zeroizing<String>> = match peek_msg(&recv) {
    Some(m) if ProfileUnlock::is(&m) => Some(Zeroizing::new(ProfileUnlock::decode(&consume(&mut recv).await?)?.passcode)),
    _ => None,
};
// (2) Resolve per the D4 table. PasscodeGate is keyed (profile_id, client_fp); ONE try this connection.
let resolved: ResolvedProfile = match profiles.resolve_session_profile(
        &fingerprint_hex, hello.profile_id.as_deref(), passcode.as_deref().map(|z| z.as_str())) {
    Ok(r) => r,
    Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); } // typed close; device stays paired
};
// (3) SEC-3 fail-closed gate + occupancy + (Linux) broker open / (Windows) console target — §7.4 / §8.4.
//     Any deny → ProfileReject{NeedsIsolation|Occupied|SessionUnavailable{..}} and bail, BEFORE the pipeline.
// (4) Build the pipeline; set Welcome { …, resolved_profile: Some(resolved.id) }; proceed; audit (§6.… / §7.…).
```

There is **no in-handshake backoff sleep** — a 60 s backoff or 15 min lockout cannot run inside the
10 s `HANDSHAKE_TIMEOUT` (`punktfunk1.rs:327/702`). Rate limiting is **cross-connection** in the
`PasscodeGate`; a wrong passcode rejects fast and the client reconnects for the next try.

### 5.4 `Welcome` — resolved-profile echo

`Welcome` (`quic.rs:164`) drops `Copy` (derive `Clone, Debug, PartialEq, Eq` — small, off the
per-frame path). Append after `color` (`quic.rs:721-751`):

```rust
/// The profile id the host PLACED this session in (echoes the RESOLVED id — not the display name;
/// the client needs the id to reland + seed the next connect). None = single-user / old host.
/// Appended after `color` as `len u8 || UTF-8` at offset 64.
pub resolved_profile: Option<String>,
```

### 5.5 C ABI (`abi.rs`) — `connect_ex6` (D2 canonical)

Append-only status variants (`error.rs:34`, with matching `PunktfunkError::*` + `status()` arms at
`error.rs:51`): `AuthRequired = -11` (passcode required **or** wrong), `NotFound = -12` (unknown
profile id), `Occupied = -13`, `SessionUnavailable = -14`, `NeedsIsolation = -15`.

```c
PunktfunkConnection *punktfunk_connect_ex6(
    const char *host, uint16_t port,
    uint32_t width, uint32_t height, uint32_t refresh_hz,
    uint32_t compositor, uint32_t gamepad, uint32_t bitrate_kbps, uint8_t video_caps,
    const char *launch_id,
    const char *profile_id,        /* NULL => default profile (today's behavior) */
    const char *passcode,          /* NULL => none; connector sends ProfileUnlock iff non-NULL */
    const uint8_t *pin_sha256,
    uint8_t *observed_sha256_out,
    const char *client_cert_pem, const char *client_key_pem,
    PunktfunkStatus *status_out,   /* nullable; receives the typed failure */
    uint32_t timeout_ms);
```

- `connect_ex5` delegates with `profile_id = NULL, passcode = NULL, status_out = NULL` (its body moves
  into `ex6`). `ex6` parses `profile_id`/`passcode` via `opt_cstr`, threads them into
  `NativeClient::connect`, and on `Err(e)` writes `*status_out = e.status()` before returning null.
- Resolved-profile accessor: `const char *punktfunk_connection_profile(const PunktfunkConnection *conn)`
  → the resolved profile id the host echoed (`""`/NUL-terminated if None).
- Enumerate (standalone, like `punktfunk_pair`):

```c
typedef struct PunktfunkProfile {
    char id[65]; char display_name[65];
    uint8_t passcode_required; uint8_t assigned;   /* assigned flag per D9 */
} PunktfunkProfile;

PunktfunkStatus punktfunk_list_profiles(
    const char *host, uint16_t port,
    const uint8_t *pin_sha256, uint8_t *observed_sha256_out,
    const char *client_cert_pem, const char *client_key_pem,
    PunktfunkProfile *out, size_t cap, size_t *count_out, uint32_t timeout_ms);
// fills min(cap, available); *count_out = total available. The host NUL-terminates each char[65].
```

(The wire `ProfileEntry` carries `accent`/`avatar`; the fixed C struct drops them — native pickers use
a monogram fallback, or fetch richer detail via the REST enumerate if needed.) Regenerate
`include/punktfunk_core.h` (cbindgen; CI checks drift).

### 5.6 `NativeClient` (`client.rs`)

`connect` (`client.rs:238`) gains two params after `launch`:

```rust
pub fn connect(host:&str, port:u16, mode:Mode, compositor:CompositorPref, gamepad:GamepadPref,
    bitrate_kbps:u32, video_caps:u8, launch:Option<String>,
    profile:Option<String>, passcode:Option<String>,   // NEW
    pin:Option<[u8;32]>, identity:Option<(String,String)>, timeout:Duration) -> Result<NativeClient>
```

`worker_main` sets `Hello.profile_id = profile`; **iff `passcode` is non-NULL**, writes one
`ProfileUnlock { passcode }` immediately after the Hello, then reads the next message: a `Welcome`
(success) or a `ProfileReject` → typed error (`reason → PunktfunkError`). `Negotiated`
(`client.rs:45`) gains a 9th element `resolved_profile: Option<String>` from `welcome.resolved_profile`;
add `pub fn resolved_profile(&self) -> Option<&str>`. New
`NativeClient::list_profiles(host, port, identity, pin, timeout) -> Result<(Vec<ProfileEntry>, [u8;32])>`
mirrors `pair()`: open a bi-stream, write `ListProfiles`, read `ProfileList`, return entries + observed
fingerprint.

**Caller updates:** every `NativeClient::connect` site adds `None, None` until its UI lands
(`clients/linux/src/{session,app}.rs`, `clients/windows/src/session.rs`,
`clients/android/native/src/session.rs`, host self-tests). Every `Hello { … }` literal adds
`profile_id: None` (the `quic.rs` test constructors; `clients/probe/src/main.rs` gets `--profile` /
`--passcode` flags). Every `Welcome { … }` literal adds `resolved_profile: None`.

### 5.7 mDNS + REST mirror

- mDNS TXT gains `prof=1` (`discovery.rs:56`) so clients show the profile UI only against a supporting
  host (and skip a wasted `ListProfiles` to an old host).
- REST `GET /api/v1/profiles/enumerate` (cert-allowlisted, **same scoping** as the control stream) is
  the operator-console / pre-connect secondary path. The **control-stream `ListProfiles` is PRIMARY**
  for native clients (no mgmt-port knowledge needed mid-connect). `GET /api/v1/profiles` stays
  **bearer-only** (full admin list).

### 5.8 Back-compat matrix

| Client \ Host | **Old host** (no profiles) | **New host** (profiles built) |
|---|---|---|
| **Old client** (no `profile_id`) | Today's single session. Unchanged. | Hello lacks the trailing `profile_id` → `requested=None` → assignment-or-default (D4). Old client cannot answer a passcode → a passcode-gated profile is unreachable; such a device must be **assigned** or stays on the default. `resolved_profile` ignored by the old decoder. **Works.** |
| **New client** | Trailing `profile_id` ignored by old `Hello::decode`; old host runs its single session. `Welcome` omits `resolved_profile` → client sees `None`, hides the picker. `prof` absent in TXT → no profile UI. A stray `ListProfiles` to an old host fails first-message decode → connector falls back to a plain `connect(profile=None)`. **Graceful.** | Full feature: `connect_ex6` carries `profile_id`; connector sends `ProfileUnlock` iff passcode non-NULL; Welcome echoes the resolved id. |

A `Hello` with `profile_id=None` and no new fields is **byte-identical** to today's Hello.

---

## 6. Security model & threat analysis

### 6.0 Principals, assets, adversaries

**Principals:** *Operator* (owns the box; holds the mgmt bearer token,
`~/.config/punktfunk/mgmt-token` 0600, constant-time compared `mgmt.rs:532`; sole editor of
`profiles.json`; root-equivalent). *Paired device* (a client cert pinned in `punktfunk1-paired.json`,
mTLS-authenticated every connect). *Profile user* (a real OS account a profile maps to). *Shared
device* (one cert used by several humans — a family Apple TV).

**Assets (ranked):** OS-session-creation capability (the broker) > each profile user's desktop bytes +
input stream > the Windows auto-login credential blob > `profiles.json` (OS-user map, passcode hashes,
assignments) > mgmt token > per-session AES-GCM keys (already per-session random in `Welcome.key/salt`,
`quic.rs:172-173` — no new work).

**Adversaries:** (A) hostile unpaired LAN device; (B) paired-but-malicious device / stolen client key;
(C) unprivileged local OS user on the box; (D) a passenger on a shared device wanting another human's
profile; (E) the network-facing host post-RCE (codec/QUIC bug).

### 6.1 Privilege split (D7) and its invariants

The §3.2 invariant is realized by the zone split (§3.3). The do-not-regress invariants:

- **SEC-1 — no cross-uid byte access.** A profile user's *decoded* frames, raw input, and mic audio
  are handled **only** by a process running as that uid (the zone-2 worker). Zone 1 sees only encoded
  media + forwards opaque input. This supersedes the shared-`/tmp` EIS socket of the old
  `gamescope-multiuser.md` — the sockets now live in the user's `XDG_RUNTIME_DIR`.
- **SEC-2 — broker trusts only the profile id.** The broker resolves `profile_id → {uid, username}`
  from its **own** root-owned read of `profiles.json`, refuses an absent id, and **never** accepts a
  raw uid/username/SID over IPC. A compromised zone 1 can only open sessions for OS users the operator
  already mapped — not `root`, not an arbitrary account.
- **SEC-3 — isolation-required backends.** A profile whose OS user ≠ operator MUST run on an isolating
  gamescope per-uid worker; shared-desktop backends (kwin/mutter/wlroots) serve the operator profile
  **only**. Enforced fail-closed at resolve time (§6.3).

### 6.2 Host privilege model per platform

**Linux — root session-broker + per-uid worker (D7).** `punktfunk-session-broker` is a `root` systemd
**system** service; `punktfunk-host` stays non-root. Broker hardening: caps
`CAP_SETUID/SETGID/SETPCAP/KILL` **only** — `CAP_SYS_ADMIN` and `CAP_DAC_OVERRIDE` are **dropped**
(the broker creates no namespaces and root-owner bits already grant the reads it needs; keeping them
would make the "tiny TCB" claim notional). `RestrictAddressFamilies=AF_UNIX` (never `AF_INET*`),
`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `MemoryDenyWriteExecute=yes`
(**re-verified** against the PAM module loader in the Phase-0 spike — if a module trips it, document
the `MDWX=no` exception). Full unit + handler in §7.1.

**Windows — mirror the split (target), SYSTEM front-door (v1 gap).** Target: non-SYSTEM streamer +
tiny SYSTEM broker with a verified client SID (the Windows analogue of SEC-2). **v1 may keep the
front-door as SYSTEM** but then **does NOT satisfy** the invariant — a host RCE = full box compromise
+ recovery of every stored credential. This is documented as a residual; operators run Windows
multi-user trusted-LAN only (§8.1, §13 Phase 3 gate).

### 6.3 Fail-closed isolation gate (SEC-3 / D5)

> The SEC-3 check lives **with resolution**, never "resolve-then-hope." At resolve time, if
> `os_account != Operator` **and** the isolating worker/broker is not present/available, the host
> **rejects** with `SessionUnavailable{NoBroker}` / `NeedsIsolation` **before the session pipeline is
> built**. "Non-Operator profile served on a shared host-lifetime injector/audio/mic singleton" is an
> **impossible state** — it would inject device D's keystrokes into another user's live desktop (the
> catastrophic leak).

Operator profiles keep today's shared-desktop multi-view (KWin/Mutter/wlroots) and are multi-occupant.
The brokered worker **always forces `Compositor::Gamescope`** regardless of the operator's own
compositor, so a Mutter/KWin operator desktop coexists with isolated gamescope users on the same box
(§7.4).

### 6.4 The passcode as a second factor (D1/D10)

The passcode is an **optional per-profile** secret gating *shared-device* access (decision #2). It is
**not** the OS password and never touches PAM/LogonUser auth directly (except as the Windows
credential-wrap key, §8.6).

- **Storage/verify:** Argon2id PHC only, m=19456/t=2/p=1, decoy-verify on miss, host-side
  (§4.3). Never logged, never echoed in errors.
- **Plaintext-over-channel is sound here:** the channel is QUIC TLS 1.3 (confidential) **and**
  cert-mutually-authenticated (the device is already paired) — unlike the *pairing* PIN, where SPAKE2 is
  needed because no trust exists yet. So a plain host-side verify is correct and a PAKE only adds a
  round trip. The passcode rides the never-logged `ProfileUnlock`, **never** Hello.
- **One attempt per connection; cross-connection `PasscodeGate`** keyed `(profile_id, client_fp)` so
  one device cannot DoS-lock a profile for others; Windows persists the counters across the
  console-switch relaunch.
- **Stolen paired key (adversary B):** an attacker with a device's key+cert lands in the profiles that
  device is **assigned** to (frictionless skips the passcode). For high-value profiles the operator
  sets `require_passcode_even_when_assigned = true`, turning the cert into a *first* factor and the
  passcode into a *true second* factor (a stolen key alone is then insufficient). Recovery from a
  stolen key is unpairing the fingerprint (`DELETE /api/v1/native/clients/{fp}`).

### 6.5 Occupancy (D6)

Single-occupancy is keyed by the **resolved Linux uid / Windows SID**, not the profile id — so two
profiles mapping to the same OS user can't both occupy it (Steam single-instance / `/run/user/<uid>` /
socket-name collisions). **Operator profiles are exempt** (multi-occupant, preserves
`--max-concurrent` multi-view). A `profiles.json` mapping two profiles to the same uid/SID is rejected
at load/validate. A reconnect by the **same device to the same profile** preempts (terminate the prior
session, like the IDD-push reconnect-preempt `punktfunk1.rs:2449-2456`); a **different** profile while
one is active → `Occupied`. On Windows occupancy is **global** (single console), persisted in the
SYSTEM broker so a console-switch relaunch can't double-grant.

### 6.6 tvOS identifier is a UI hint, not a trust factor (D8)

`TVUserManager.currentUserIdentifier` is client-asserted and Apple provides **no signed token** → the
host **cannot** verify it, so it **never rides the wire**. It is used **only** for client-side
auto-selection of the mapped profile. A `tvos_user_ids` match **still requires the profile's passcode**
for any passcode-protected profile (no skip). There is **no `tvos_attested`** wire signal. An
un-entitled build degrades to the manual picker + passcode — the only sound mode anyway (§11).

### 6.7 GameStream / Moonlight exclusion (D13)

Profiles are **`punktfunk/1`-only**, by protocol fact: GameStream pairing stores only the client cert
DER (no name/profile, `gamestream/pairing.rs`), the catalog is the single global `apps.json`, and
`nvhttp.rs` holds one `Mutex<Option<LaunchSession>>` (one global session). There is no Hello-equivalent
to carry a profile id/passcode and no per-user identity to map. A host with `profiles.json` **and**
`serve --gamestream` MUST **pin every GameStream session to the operator profile** and emit an audit
line recording the pinning. Stated in the docs and the `profiles` OpenAPI tag description.

### 6.8 Threat model with explicitly-stated residuals

| # | Residual | Mitigation / posture |
|---|---|---|
| R1 | **Host-RCE input-injection into live sessions (SEC-1 residual).** SEC-1 protects byte *confidentiality* and session *creation*, not input *integrity*: zone 1 forwards opaque input, so an RCE'd zone 1 can synthesize keyboard/mouse into a currently-active profile desktop (code-exec as that user) — it just can't read the pixels. | The **worker** (not zone 1) owns + rate-limits the EIS/uinput grant; per-session authorization bounds blast radius. Documented: a host RCE yields input-level code-exec into *live* sessions. |
| R2 | **Windows v1 SYSTEM front-door (D7 gap).** A codec/QUIC RCE = full box compromise + recovery of every stored credential — the very "host-as-root" posture rejected for Linux. | Documented; mirror-split is the target architecture; v1 = trusted-LAN-only, GameStream-style posture (operator's explicit go/no-go, §13 Phase 3). |
| R3 | **tvOS client-asserted identifier (D8).** A modified/stolen shared-TV key can claim any `currentUserIdentifier`. | The identifier is a UI hint only; the **only** real factors on a shared TV are the device cert (device-level) + the passcode (human-level). Passcode is required regardless of any tvOS "match." |
| R4 | **`LibraryScope` is curation, not a sandbox (D12).** Visibility filtering only; once in their own session the user can launch anything via the store UI; installed titles are the target user's own. | Launch-by-id resolution is scoped (a crafted `Hello.launch` can't bypass a Deny). Real restriction needs OS-level parental controls. Never marketed as enforcement. |
| R5 | **Passcode-less Windows credential recoverable from a stolen disk image (D10).** `CRYPTPROTECT_LOCALMACHINE` blobs decrypt from the SYSTEM/SECURITY hives on the same disk, not only by a live SYSTEM process. A 4-digit wrap is also offline-crackable against the GCM blob in minutes. | Entropy floor ≥6 alphanumeric for credential-wrapping passcodes; bind the wrap KDF to a TPM-sealed / host-identity-derived secret; reserve passcode-less credentials for **dedicated low-privilege local accounts** (never a domain/admin account). |
| R6 | **Zone-1 RCE can open sessions for any mapped user** (not root/unmapped). | SEC-2 + `SO_PEERCRED` + broker syscall filter + per-open audit (zone-0 stream a zone-1 RCE can't erase). Map profiles to dedicated low-priv accounts. |
| R7 | **Passwordless `/etc/pam.d/punktfunk` is a standing box-wide service.** Any root-context caller of `pam_start("punktfunk", user)` gets a passwordless session. | Documented broker-only + security-sensitive; harden with `pam_succeed_if` to the mapped accounts; keep `account required pam_unix.so` so disabled/expired accounts are refused. |

**Audit (`punktfunk::audit`, structured, never secrets):** profile resolution (every connect:
`client_fp`, `profile_id`, outcome); passcode attempts (`result`, `failures_so_far` — never the
passcode); OS-session create/teardown (the **broker, zone 0** — the one record a zone-1 RCE can't
tamper: `profile_id`, `uid`, `xdg_session_id`, `worker_pid`, open/close+reason); profile administration
(mgmt mutations, operator principal). Windows writes the corresponding Event Log entries.

---

## 7. Linux host

The Linux realization of the privilege split (D7), the fail-closed isolation gate (D5), uid-keyed
occupancy (D6), lifecycle/reaping (D11), and the honest cold-login + packaging story (D12). On Linux
the whole feature is the difference between **`OsAccount::Operator`** (today's shared desktop,
unchanged) and **`OsAccount::Linux{username, uid}`** (a real OS user, auto-logged-in,
input/audio-isolated).

### 7.0 The three processes and the one new boundary

```
 zone 0 (root)                  zone 1 (network, non-root)            zone 2 (target uid)
 punktfunk-session-broker       punktfunk-host (serve)                punktfunk-host session-worker
 tiny TCB, no codec/QUIC        QUIC/FEC/AES-GCM, mgmt API,           gamescope(uid) + capture
 PAM open · setuid · spawn      profiles.json (READ),                 + NVENC/VAAPI encode
   ▲     │ fork+exec            passcode VERIFY, occupancy ▲          + per-session injector(EIS)
   │ uds │ (SCM_RIGHTS:                  │  session_fd     │          + null-sink audio + mic
 OpenSession  session_fd)               ▼  framed AU/audio/input      runs INSIDE the uid's
 /run/punktfunk/broker.sock  ◄──────────┘ ◄───────────────────────►  logind session (linger)
 (0600, SO_PEERCRED==host uid)            (lifeline = broker conn)    owns U's XDG_RUNTIME_DIR
```

**Split point.** Zone 1 stays the **sole QUIC + data-plane terminator** (`Session` = Leopard FEC +
AES-GCM + UDP `sendmmsg`). The zone-2 worker does **capture + encode + inject + audio** *as the uid*
and exchanges **opaque encoded AUs / Opus / input** with zone 1 over one inherited `SOCK_SEQPACKET`
socketpair (`session_fd`) — the exact shape of the shipping Windows two-process secure-desktop relay
(`virtual_stream_relay` `punktfunk1.rs:2408`; `capture::wgc_relay`), reusing that AU-framing
discipline so SEC-1 holds without a per-uid QUIC endpoint.

**New code surface:**

| Artifact | Kind | TCB notes |
|---|---|---|
| `crates/punktfunk-broker/` | new crate (`proto.rs` + `bin/punktfunk-session-broker.rs`) | deps only: `serde`/`serde_json`, `nix`/`libc`, `pam-sys`, `sd-notify`. **No** `quinn`/`ffmpeg`/`cuda`/`punktfunk-host`. ~few-hundred LoC. |
| `punktfunk-host session-worker` | new subcommand (sibling to `service`/`driver`/`web`) | *is* zone 2; reuses `vdisplay`/`capture`/`encode`/`inject`/`audio` verbatim. |
| `crate::broker_client` | new host module | zone-1 side: connect the broker, `OpenSession`/`CloseSession`, receive `session_fd` via `SCM_RIGHTS`. |
| `crate::session_relay` | new host module | zone-1 ↔ worker `session_fd` framing + `virtual_stream_brokered`. |
| `scripts/punktfunk-session-broker.{socket,service}` · `scripts/pam/punktfunk` · `scripts/tmpfiles/punktfunk.conf` | systemd/PAM/tmpfiles | shipped by deb/rpm/copr/arch/bootc. |

### 7.1 Zone 0 — `punktfunk-session-broker`

**Socket + peer auth.** Created by **systemd socket activation** (`ListenStream=/run/punktfunk/broker.sock`,
`SocketUser=punktfunk`, `SocketMode=0600`) so ownership/mode are declarative. The broker re-checks
`SO_PEERCRED` on every `accept()` and rejects any peer whose `ucred.uid != configured host uid`
(adversary C must not call it even if the DACL were widened).

**Wire protocol (passcode never crosses here):**

```rust
// crates/punktfunk-broker/src/proto.rs   (shared by broker + crate::broker_client)
pub const PROTO_VERSION: u32 = 1;

#[derive(Serialize, Deserialize)]
pub enum BrokerRequest {
    /// Host passes ONLY a profile_id (SEC-2: broker re-reads profiles.json itself). client_fp is for
    /// the broker's independent audit. The authoritative (re-clamped) mode rides SessionInit over
    /// session_fd, so the LAUNCH command never enters the root TCB.
    OpenSession { proto: u32, profile_id: String, client_fp: String, mode: Mode },
    CloseSession { session_id: u64 },   // idempotent; lifeline EOF is the crash-path reaper
}
#[derive(Serialize, Deserialize)]
pub enum BrokerReply {
    Opened { session_id: u64, uid: u32, worker_pid: u32 },  // session_fd via SCM_RIGHTS, out-of-band
    Error  { kind: BrokerError, msg: String },
}
#[derive(Serialize, Deserialize, Clone, Copy)]
pub enum BrokerError {
    NoSuchProfile,   // absent from broker's own profiles.json read (SEC-2)
    NotIsolatable,   // resolves to Operator — zone-1 bug, must not brokerage it
    MissingGroups,   // target uid lacks render/video/input (D12) — msg names which
    LingerFailed, PamFailure, SpawnFailure, Busy,
}
```

`Mode` is a leaf re-export of `punktfunk_core::Mode` so the crate doesn't pull all of core. Framing:
4-byte LE length + `serde_json` (auditable, no custom parser to fuzz); `SOCK_SEQPACKET` for
kernel-enforced message boundaries.

**`OpenSession` handler order** — the non-obvious part is making a **cold** OS user (never
interactively logged in, headless box, no seat) get a real `XDG_RUNTIME_DIR`, user D-Bus, and tracked
logind session:

1. **`SO_PEERCRED` gate** — `ucred.uid == host_uid` or `Error`.
2. **Resolve `profile_id → (username, uid)` from the broker's OWN read of `profiles.json`** (root-owned;
   `stat`+reload per request so mgmt edits are picked up; a parse failure denies, never serves stale).
   Reject `OsAccount::Operator` with `NotIsolatable`; absent id with `NoSuchProfile`.
3. **Group preflight (D12)** — `getpwnam_r` + `getgrouplist`; require `render`, `video`, `input` (GPU
   `renderD128`; `uinput`/`uhid`; PipeWire). On a miss → `MissingGroups{msg:"user 'kids' not in: render, input"}`
   → surfaced to the client as `SessionUnavailable{LoginFailed}` with that reason, not a black screen.
4. **`loginctl enable-linger`** (`org.freedesktop.login1.Manager.SetUserLinger(uid,true,false)`,
   idempotent) — provisions `/run/user/<uid>` (0700) and starts `user@<uid>.service` (PipeWire/
   WirePlumber) with no seat/graphical login. On failure → `LingerFailed`.
5. **PAM session** with the items `pam_systemd` needs to classify a `user` (not `background`) session:
   ```text
   pam_start("punktfunk", username, &conv_null, &pamh)
   pam_set_item(PAM_TTY, "pts/punktfunk")     pam_set_item(PAM_RUSER, "root")
   pam_putenv("XDG_SESSION_CLASS=user")        pam_putenv("XDG_SESSION_TYPE=wayland")
   pam_putenv("XDG_SESSION_DESKTOP=gamescope")
   pam_authenticate(0)   // pam_permit — passwordless (broker is root)
   pam_acct_mgmt(0)      pam_setcred(PAM_ESTABLISH_CRED)   pam_open_session(0)
   ```
6. **Verify the session class** (`sd_uid_get_state` / inspect the new `XDG_SESSION_ID`) is `user`; a
   `background` class → close + `PamFailure` (belt-and-suspenders with linger, which already guarantees
   the runtime dir).
7. **`fork()`**; child: `pam_getenvlist` → set the session env → `setgid(pw_gid)` →
   `initgroups(username, pw_gid)` (picks up render/video/input) → **`setuid(uid)`** →
   **re-assert `getuid()==uid && setuid(0)==EPERM`** (drop is irreversible) →
   `prctl(PR_SET_PDEATHSIG, SIGKILL)` → **drop the capability bounding set** (worker holds zero caps) →
   dup the worker `session_fd` end to fd 3 → `execve punktfunk-host session-worker --session-fd 3 --mode WxHxHz --uid N`.
8. Parent records `session_id → SessionRec{pamh, worker_pid, uid, peer_conn}`, writes the **zone-0
   audit** line, replies `Opened` with the **zone-1 end** of the socketpair via `SCM_RIGHTS`.
9. **Teardown** (on `CloseSession`, lifeline EOF, or worker `SIGCHLD`): `pam_close_session` +
   `pam_setcred(PAM_DELETE_CRED)` + `pam_end`; `kill(worker_pid)` if alive; `waitpid`; audit `close`.
   Linger is left enabled (a provisioning property of the user) unless the operator unprovisions.

**Hardening unit** (`scripts/punktfunk-session-broker.service`): `Type=notify`, `User=root`,
`CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_KILL`, `AmbientCapabilities=`,
`NoNewPrivileges=no` (the broker's whole job is to change uid), `RestrictAddressFamilies=AF_UNIX`,
`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `ProtectKernelModules=yes`,
`MemoryDenyWriteExecute=yes` (re-verified in the Phase-0 spike). **PAM service** `scripts/pam/punktfunk`:

```text
auth     required   pam_permit.so     # passwordless — broker is already root
account  required   pam_unix.so       # still honors account lock/expiry
session  required   pam_systemd.so    # registers the logind session + XDG_RUNTIME_DIR
session  required   pam_unix.so
```

### 7.2 Zone 2 — `punktfunk-host session-worker` (per-uid)

The existing per-session machinery, but **its own process, running as the uid**, with **per-session**
injector/audio/mic instead of the host-lifetime singletons. Re-entered as `punktfunk-host
session-worker` (no second binary). Entry sketch:

```rust
// crates/punktfunk-host/src/session_worker.rs
pub fn run(opts: WorkerOpts) -> Result<()> {
    let mut chan = session_relay::WorkerChannel::from_raw_fd(opts.session_fd);   // fd 3 → zone 1
    let init = chan.recv_init()?;   // SessionInit{ mode, launch_cmd, bitrate_kbps, bit_depth }

    // (1) Force the isolating backend — a FRESH nested gamescope as this uid (own EIS/GPU/PipeWire/
    //     null-sink). SEC-3 by construction; the client's + operator's compositor prefs are irrelevant.
    let mut vd = vdisplay::open(Compositor::Gamescope)?;
    vd.set_launch_command(init.launch_cmd);   // per-instance, never the process-global env (vdisplay.rs:56)

    // (2) Same retry helper zone-1 uses — capture+encode run HERE.
    let plan = session_plan::SessionPlan::resolve(init.bit_depth);
    let (mut capturer, mut enc, ..) =
        build_pipeline_with_retry(&mut vd, init.mode, init.bitrate_kbps, init.bit_depth, plan)?;

    // (3) Per-session, uid-owned side services (NOT the host-lifetime singletons):
    let injector = SessionInjector::start(vd.current_ei_socket());     // EIS bound to THIS socket
    let audio    = SessionAudio::start(vd.audio_sink_node())?;         // null-sink monitor
    let mic      = SessionMic::start();                                // "punktfunk-mic-<pid>"

    // (4) encode → frame the AU → write to fd 3. Input/mic come back the other way.
    run_worker_loop(&mut capturer, &mut enc, &mut chan, &injector, &audio, &mic)
}
```

`build_pipeline_with_retry`/`build_pipeline` (`punktfunk1.rs:3430/3499`) are reused **as-is**. The only
refactor is the **AU sink seam** — today `virtual_stream` pushes each encoded AU into the in-process
`Session`; extract a trait:

```rust
pub trait AuSink { fn push_au(&mut self, au: &[u8], pts_ns: u64, keyframe: bool) -> Result<()>; }
// zone-1 Operator path:  impl AuSink for &mut Session    (today's behavior; nothing moves)
// zone-2 worker path:    impl AuSink for WorkerChannel   (writes a tagged frame to fd 3)
```

Zero-copy paths (`frame.is_cuda()`) are untouched — the worker is just a different consumer of the
same encoded bytes (5K@240: the AU is already one `sendmmsg`-batched object; the SEQPACKET hop is one
`write`, same as the Windows relay). Gamepads stay correct: the worker owns its uinput/uhid pads (from
the resolved `welcome.gamepad`), so the devices live in the uid's namespace and rumble/HID flows back
over fd 3 → zone 1 → client datagram.

### 7.3 The four gamescope-multiuser isolation pieces (now mandatory)

These are `gamescope-multiuser.md`'s parked plumbing, now mandatory and hardened to live inside the
worker (sockets in the uid's `XDG_RUNTIME_DIR`, not shared `/tmp` — the SEC-1 improvement):

1. **Per-instance EIS socket → new `VirtualOutput` field.** `vdisplay.rs:29-47` gains
   `#[cfg(target_os="linux")] pub ei_socket_file: Option<PathBuf>`. `vdisplay/linux/gamescope.rs`
   replaces the global `EI_SOCKET_FILE="/tmp/punktfunk-gamescope-ei"` (`:778`) with a per-instance path
   `format!("{}/punktfunk-gamescope-{id}-ei", env("XDG_RUNTIME_DIR"))`; `current_ei_socket()` hands it
   to the worker. The legacy global path remains **only** on the Operator attach/managed paths.
2. **Per-session injector bound to that socket.** `inject.rs:24` `enum Backend` gains
   `GamescopeEiAt(PathBuf)` → `libei::LibeiInjector::open_with(EiSource::SocketPathFile(path))`. New
   `SessionInjector` in `session_worker.rs`: own thread (the libei `InputInjector` is `!Send`),
   **lazy-open on first event** (the socket file doesn't exist until the nested compositor is up). Owns
   the per-session uinput/uhid pads; emits rumble/HID back to the worker channel. The host-lifetime
   `InjectorService` (`:167`) stays for Operator/portal backends.
3. **Per-session audio null-sink + monitor.** The worker (as the uid) creates a null-sink per session
   (`module-null-sink sink_name=punktfunk-<pid> media.class=Audio/Sink`), routes the nested apps to it,
   and captures **that sink's monitor**. New `open_audio_capture_for(channels, target_node)` sets
   `PW_KEY_TARGET_OBJECT`/`node.target` instead of `PW_ID_ANY`. The host-lifetime `AudioCapSlot`
   (`punktfunk1.rs:212`) keeps the default-monitor path for Operator.
4. **Per-session virtual mic.** The worker opens `punktfunk-mic-<pid>` via `open_virtual_mic` into its
   gamescope; the global `MicService` (`punktfunk1.rs:1121`) stays for Operator.

Net: **zero** shared injector/audio/mic state crosses a uid boundary — the catastrophic leak is
structurally impossible.

### 7.4 The fail-closed SEC-3 gate (D5)

In `serve_session`, immediately after resolution, **before** the data-plane spawn (`:958`):

```rust
let resolved: ResolvedProfile = match profiles.resolve_session_profile(&fp_hex, req_id, passcode) {
    Ok(r) => r,
    Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); }
};
let route: SessionRoute = match resolved.os_account {
    // (A) Operator → today's shared-desktop path, UNCHANGED. Multi-occupant (D6).
    OsAccount::Operator => SessionRoute::Operator { compositor: resolve_compositor(hello.compositor)? },
    // (B) A real Linux user → MUST run on the isolating gamescope worker. Fail CLOSED here.
    OsAccount::Linux { uid, .. } => {
        let uid = uid.expect("resolve stamps uid at create-time");
        if !broker_client::is_available() {                 // Flatpak / unprovisioned host (D12)
            write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); }   // NEVER fall through
        if !vdisplay::gamescope_present() {
            write_profile_reject(&mut send, NeedsIsolation).await?; return Ok(()); }
        let occ = match occupancy.try_acquire(uid, &fp_hex) {   // D6: by RESOLVED uid; Operator exempt
            Some(g) => g,
            None => { write_profile_reject(&mut send, Occupied).await?; return Ok(()); }
        };
        let session = match broker_client::open(&resolved.id, &fp_hex, hello.mode) {
            Ok(s) => s,
            Err(BrokerError::MissingGroups | BrokerError::LingerFailed
              | BrokerError::PamFailure | BrokerError::SpawnFailure)
                       => { write_session_unavailable(&mut send, LoginFailed).await?; return Ok(()); }
            Err(_)     => { write_session_unavailable(&mut send, NoBroker).await?;    return Ok(()); }
        };
        SessionRoute::Brokered { uid, occ, session }
    }
};
```

**The KWin/Mutter/wlroots-operator question, answered:** a non-Operator profile is **never** served on
those shared backends (they create a virtual output *inside the operator's live session* —
`remote_fd:None`, no isolation = the SEC-3 violation). The worker always forces gamescope, so a Mutter
or KWin operator desktop coexists fine with isolated gamescope users on the same box. The only
rejections are when isolation can't be stood up (`NoBroker` / `NeedsIsolation` / `LoginFailed`) — never
a silent fallthrough, never a black screen. Synthetic source and `--gamestream` are unaffected
(GameStream pinned to Operator, D13; the synthetic test source has no compositor).

### 7.5 Threading the route through `SessionContext` / pipeline / accept loop

The accept loop (`punktfunk1.rs:167-323`) keeps the host-lifetime singletons (`audio_cap` `:212`,
`injector` `:217`, `mic_service` `:221`) — they now serve **only Operator** sessions. Add two
host-lifetime handles cloned into each spawned session: `occupancy: Arc<Occupancy>` and
`broker: Arc<BrokerClient>` (lazily-connected; error ⇒ `is_available()==false`). `SessionContext`
(`:2352-2391`) replaces the bare `compositor` with a route + a logging id:

```rust
enum SessionRoute {
    Operator { compositor: crate::vdisplay::Compositor },     // capture+encode in zone 1 (today)
    Brokered { uid: u32, occ: OccupancyGuard, session: BrokerSession },  // capture+encode in the worker
}
// SessionContext gains:  route: SessionRoute,  resolved_profile_id: String   // for GET /api/v1/sessions (D11)

struct Occupancy { by_uid: Mutex<HashMap<u32, ClientFp>> }    // host-lifetime, Arc
impl Occupancy { fn try_acquire(&self, uid: u32, fp: &str) -> Option<OccupancyGuard>; } // Operator never calls this
```

`virtual_stream` (`:2393`) branches at the top exactly like the Windows `TwoProcessRelay` branch
(`:2407-2410`): `if let SessionRoute::Brokered { .. } = ctx.route { return virtual_stream_brokered(ctx); }`.
`virtual_stream_brokered` runs the zone-1 half: send `SessionInit{ mode, launch_cmd (resolved against
the profile's **scoped** library, §4.6), bitrate_kbps, bit_depth }` over `session_fd`, then loop
reading framed AUs/Opus and pushing them into `ctx.session` (FEC/crypto/UDP) while input/mic/
reconfigure/keyframe go the other way. The Operator path's `input_thread`/`audio_thread` are skipped
for brokered sessions — input/mic forward to the worker, audio arrives from its null-sink monitor, so
the host-lifetime injector/audio/mic are never touched by a non-Operator session.

### 7.6 Lifecycle, reaping, Reconfigure (D11)

**Teardown ordering** (mirrors the existing teardown at `:1028`): `stop.store(true)` →
`BrokerSession::drop` sends `CloseSession` → broker `pam_close_session` + `kill(worker)` + `waitpid` +
audit `close` → `OccupancyGuard::drop` frees the uid slot. The worker, on `session_fd` EOF or `SIGTERM`,
drops its gamescope keepalive, `SessionInjector` (destroys the per-session pads + EIS), and
null-sink/mic.

**Orphan reaping (the lifeline).** The broker treats the **zone-1 connection** as each session's
lifeline: if the host dies (crash, console-change restart, OOM), every `SessionRec` on that connection
is reaped (`pam_close_session` + kill + waitpid). Redundant nets: `PR_SET_PDEATHSIG=SIGKILL`; the worker
self-exits on `session_fd` EOF; on broker startup it sweeps `/run/punktfunk/sessions/*.pid` and kills
survivors from a previous broker generation before accepting.

**Reconfigure re-clamp (D11).** The mid-stream `Reconfigure` (`punktfunk1.rs:722`) is **re-intersected
with the resolved profile's `SessionDefaults`** (not just the initial Hello) via
`clamp_mode(requested, resolved.session_defaults)`, then for a brokered session **forwarded to the
worker** over `session_fd` (a `Reconfigure{mode}` control frame). The worker rebuilds gamescope+capture+
encoder at the new clamped mode via `build_pipeline_with_retry` (gamescope can't change output mode
live → relaunch the nested compositor; the data plane in zone 1 runs on). The Operator path keeps its
in-process rebuild.

**Observability (D11).** `GET /api/v1/sessions` reads the occupancy registry +
`SessionContext.resolved_profile_id`/`uid`/`route`; `DELETE /api/v1/sessions/{id}` sets `stop` + drops
the `BrokerSession` + frees occupancy (the web console **Reclaim** action).

### 7.7 Honest cold-login scope + packaging (D12)

**What Linux delivers:** a brokered profile gets a **cold auto-login into a gamescope game-mode
session** (a fresh nested gamescope running the resolved title, or Steam Big-Picture via the managed
`gamescope-session-plus`/SteamOS paths), **not** the user's full KDE/GNOME desktop — that is the
SEC-3-required isolating backend. A **full-desktop-per-user** path (logging the uid into their own
KWin/Mutter and capturing *that* in isolation) is a **later scope**. Product framing: "stream into a
user's games, isolated" now; "stream into a user's full desktop" later.

**Packaging:**

| Package | Ships broker + PAM + units? | Capability |
|---|---|---|
| **deb / rpm / COPR / Arch / sysext / Bazzite bootc** | yes — `/usr/lib/punktfunk/punktfunk-session-broker`, `/etc/pam.d/punktfunk`, both systemd units, tmpfiles.d; `postinst`/`%post` enables `punktfunk-session-broker.socket` | Operator **and** Linux-user |
| **Flatpak / sandboxed** | **no** (can't install a root service) | **Operator-only**, advertised via a capability flag; a Linux-user profile returns `SessionUnavailable{NoBroker}` — never a silent operator fallthrough |

The broker is a **separate, optional package** declared `Recommends:`/`Suggests:` of `punktfunk-host`
— **not** a hard dependency. A minimal install has **no root component on disk at all** (Operator-only
by construction). The host advertises an `isolating_profiles: bool` capability (broker reachable) over
the mgmt API / mDNS TXT so the console + clients can grey out real-user profiles on an Operator-only
install.

### 7.8 Phases (mapped to the lab boxes; headless spike first)

- **Phase 0 — PAM/logind feasibility spike (headless, the de-risk).** QEMU VM. A standalone
  `broker-spike` (no QUIC, no host): throwaway user `pftest`, `enable-linger`, full
  `pam_start … pam_open_session` + `setuid`, then from the dropped child spawn
  `gamescope --backend headless -W 1280 -H 720 -r 60 -- vkcube`, connect to *that* user's PipeWire and
  capture. Assert: runtime dir provisioned cold, session `Class==user`, render-node access as the uid,
  **and `MemoryDenyWriteExecute=yes` survives the box's PAM stack.** **Gate the rest of the work on
  this passing.**
- **Phase 1 — broker + worker + the four isolation pieces.** QEMU VM (gamescope 3.16.22). Validate
  worker↔zone-1 AU relay + per-session EIS/null-sink/mic in isolation.
- **Phase 2 — SEC-3 gate + route threading + occupancy + `GET/DELETE /api/v1/sessions`.** Verify the
  Operator path byte-for-byte unchanged; broker-stopped Linux profile → `NoBroker`; missing-group user
  → `LoginFailed`; second device on the same uid → `Occupied`.
- **Phase 3 — concurrent multi-user + cross-GPU.** QEMU VM (two users, leak test — drive pad on device
  A, confirm nothing reaches user B); AMD Sway box (192.168.1.25, VAAPI worker as a non-operator uid);
  GNOME/Mutter box (192.168.1.248, operator desktop is Mutter while a Linux-user profile runs on its
  forced gamescope worker).
- **Phase 4 — cold-login honesty, packaging, docs.** deb/rpm install on the GNOME + Sway boxes; Steam
  Deck (deck@192.168.1.253) `gamescope-session-plus` coexistence.

---

## 8. Windows host

Maps a resolved `OsAccount::Windows { account_name, sid, credential }` to a real, capturable Windows
session, conforming to the D7 broker-split **target** with the v1 SYSTEM honesty caveat, and to the D11
lifecycle fixes.

### 8.1 The hard constraint + privilege posture

A Windows **client SKU** has exactly ONE interactive console session; all GPU capture (DDA/WGC/IDD-push)
follows it (`WTSGetActiveConsoleSessionId()` hardcoded in `service.rs:325`, `wgc_relay.rs:218`,
`interactive.rs:53`). True concurrent multi-user desktops are RDS/Server-only. Therefore **on Windows,
profiles are SEQUENTIAL: one active profile at a time; selecting a different profile is a console
user-switch.** Concurrency stays a Linux/gamescope capability. Occupancy is **global** (single console),
persisted across the console-switch relaunch (D6).

**Privilege (D7).** Target = the mirror split: a **non-SYSTEM** streamer front-door with
`LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a **tiny SYSTEM
broker** over ALPC/named-pipe with a verified client SID. **v1 may ship the front-door as SYSTEM**, but
the Phase-3 gate decision MUST record that Windows v1 does **not** satisfy the §3.2 invariant (a host
RCE = full box compromise + recovery of every stored credential → trusted-LAN-only posture).

### 8.2 W0 — generalize the primitives from "active console" to a target session id

New module `crates/punktfunk-host/src/windows/profile_session.rs`. The single mechanical unblock: stop
hardcoding `WTSGetActiveConsoleSessionId()`, thread an explicit `target_session: u32`.

```rust
pub struct WindowsProfileTarget {
    pub profile_id: String,
    pub account_name: String,     // "DOMAIN\\user" or ".\\user"
    pub sid: Option<String>,
    pub wts_session: u32,         // resolved WTS session id to capture + launch into
}
/// WTSEnumerateSessionsExW match account_name/sid -> an Active session id. None => needs logon (§8.5).
pub fn find_user_session(account: &str, sid: Option<&str>) -> Option<u32>;
/// Generalization of interactive::spawn_in_active_session:
/// WTSQueryUserToken(session) -> DuplicateTokenEx(TokenPrimary) -> CreateProcessAsUserW(winsta0/default).
pub fn spawn_in_session(session: u32, cmdline: &str, workdir: Option<&Path>) -> Result<u32>;
```

Refactors (behavior-preserving when `target == active console`): `interactive.rs:45`
`spawn_in_active_session` becomes a thin wrapper over `spawn_in_session(WTSGetActiveConsoleSessionId(),…)`;
`wgc_relay.rs:80` `HelperRelay::spawn` gains `target_session: u32`; `library::launch_title(id)` gains
`session: u32`. **W0 is validatable on the RTX box (192.168.1.173) single-user** (target = active
console → byte-identical).

### 8.3 W1 — resolve profile → session, occupancy, thread the token

```rust
#[cfg(windows)]
let win_target: Option<WindowsProfileTarget> = match resolved.os_account {
    OsAccount::Operator => None,                       // today's path: active console
    OsAccount::Windows { account_name, sid, .. } => {
        let _guard = occupancy().try_acquire_sid(&sid_of(&resolved), &fingerprint_hex)
            .ok_or(ProfileReject::Occupied)?;          // D6: keyed by resolved SID (global)
        let wts = find_user_session(&account_name, sid.as_deref())
            .or_else(|| logon_user_session(&resolved.id, &account_name, passcode.as_deref()).ok())
            .ok_or(ProfileReject::SessionUnavailable(LoginFailed))?;   // no session, no creds
        Some(WindowsProfileTarget { profile_id: resolved.id, account_name, sid, wts_session: wts })
    }
    OsAccount::Linux { .. } => bail!("linux profile on windows host"),
};
```

Occupancy is a host-lifetime registry **keyed by resolved SID** (persisted in the SYSTEM broker so a
console-switch relaunch can't double-grant). A reconnect by the same device to the same profile preempts
(like the IDD preempt `punktfunk1.rs:2449`); a different profile while one is active → `Occupied`.
`SessionContext` (`:2389-2390`) gains `#[cfg(windows)] profile_target: Option<WindowsProfileTarget>`;
`virtual_stream`/`_relay` read `ctx.profile_target.map(|t| t.wts_session).unwrap_or_else(active_console)`
and feed it to `HelperRelay::spawn` + `launch_title`. **W1 validatable with a SECOND pre-logged-in local
account** (`pf-test`, FUS).

### 8.4 W2 — putting the target user on the (capturable) console

**W2a — user already has a live session (reconnect).** `find_user_session` returns Active/Disconnected.
The existing supervisor (`service.rs:299-426`) already relaunches the host on
`WTSGetActiveConsoleSessionId()` change — once the console moves, capture follows. Trigger:
reconnect-to-disconnected where available. **Console-takeover guard (D12):** before switching, detect
whether the current console session is a **local/physically-present** interactive user vs a
punktfunk-driven remote one, and **refuse** (`SessionUnavailable{NeedsConsoleSwitch}`) or require
explicit operator opt-in before evicting a local human (no silent hostile takeover with data-loss risk).

**W2b — cold user (needs logon).** Mint the token from the stored credential:

```rust
fn logon_user_session(profile_id: &str, account: &str, passcode: Option<&str>) -> Result<u32> {
    let pw = profile_cred::load(profile_id, passcode)?;     // Zeroizing<String>, §8.6
    // LogonUserW(user, domain, &pw, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT) -> token
    // LoadUserProfileW(token, &mut PROFILEINFOW)           // HKCU + %USERPROFILE% resolve
    // ... token for the credential provider / RDS logon
}
```

A `LogonUser` token alone does NOT put the user on the console — establishing the interactive console
logon for a never-logged-in user requires a **custom Credential Provider** (`ICredentialProvider`, a COM
in-proc DLL the SYSTEM service signals): **Phase W3, deferred.** **Documented v1 fallback (D12):** a
cold profile requires the operator to sign that user in once (or FUS); thereafter W2a reconnect works.
Token asymmetry: to *launch processes* use `WTSQueryUserToken(session)` (the real interactive token),
NOT the `LogonUser` token (only for establishing/reconnecting the logon).

### 8.5 W4 (advanced/optional) — RDS multi-session

On a Server SKU with RDS, each logon is its own session+desktop. The W0 generalization (`spawn_in_session`
/ `HelperRelay` targeting a named WTS session) is exactly what's needed: `WTSEnumerateSessionsExW` to
discover, the credential provider / `WinStationConnectW` to establish, capture by session id → concurrent
occupancy allowed. Gated behind `PUNKTFUNK_RDS_MULTISESSION` + an SKU probe; document the RDS CAL
reality. NOT v1; the primitives are designed so it is additive.

### 8.6 The auto-login credential — `windows/profile_cred.rs`

`profiles.json` never holds the password (§4.2). New blob per profile at `config_dir()/cred/<profile_id>.bin`
(`%ProgramData%/punktfunk`), written with `write_secret_file` (DACL → SYSTEM/Administrators only).

```rust
// inner plaintext blob: { account_name: String, password: String }  (serde, zeroized after use)
pub fn store(profile_id: &str, account: &str, password: &str, passcode: Option<&str>) -> Result<()>;
pub fn load(profile_id: &str, passcode: Option<&str>) -> Result<Zeroizing<String>>;   // returns password
pub fn clear(profile_id: &str) -> Result<()>;
```

Sealing layers: (1) `CryptProtectData(CRYPTPROTECT_LOCALMACHINE)` — machine-DPAPI, SYSTEM-readable, not
portable off the box; (2) **if passcode-protected**, additionally wrap with Argon2id(passcode)→AES-256-GCM
so the blob is useless until the connect-time passcode arrives — a real second factor for the credential.
**D10 hardening:** the credential-wrapping passcode is **≥6 alphanumeric** (a 4-digit wrap is
offline-crackable against the GCM blob in minutes), and the wrap KDF is bound to a **TPM-sealed /
host-identity-derived** secret so the blob isn't crackable from a stolen disk image alone.

`CredentialRef::DpapiBlob{path}` points at `cred/<id>.bin`; `CredentialRef::None` = "user already logged
in" (W2a only). The operator writes it via a **bearer-only, Windows-only** mgmt route:
`POST /api/v1/profiles/{id}/windows-credential` body `{ account_name, password, passcode?: string|null }`
→ 204; `DELETE` clears. Plaintext travels the existing TLS mgmt channel; the host seals immediately and
never echoes/logs (§9).

### 8.7 Lifecycle fixes (D10/D11)

- **Hive/token unload (D11):** session teardown calls `UnloadUserProfileW` + `CloseHandle(token)`
  symmetric to `LoadUserProfileW` — no leaked hives/tokens across repeated switches.
- **`set_passcode` re-wrap (D10):** on a passcode-wrapped Windows profile, `set_passcode` must **re-wrap**
  (decrypt-old → encrypt-new) or surface a "credential needs re-entry" state — otherwise a passcode
  change silently breaks cold logon (the blob is under the OLD passcode). `update(os_account)` clears the
  credential blob + the resolved SID.
- **`delete(profile)`** also `profile_cred::clear`s the blob (§12.2).

### 8.8 SessionPlan / SE_TCB / GameStream

`SessionPlan::resolve` (`session_plan.rs:113`) is unchanged (capture/topology/encoder are
profile-independent) — the profile only redirects *which session* the topology's helper/launch targets,
carried in `SessionContext.profile_target`. The host already holds `SE_TCB_NAME` (SYSTEM), satisfying
`WTSQueryUserToken` + `CreateProcessAsUserW` + `LogonUser`; a non-SYSTEM dev `serve` cannot do profiles
→ falls back to operator/console. GameStream is pinned to operator: its stream loop and `launch_title`
calls pass `target = active console`, never a `profile_target`, with an audit line per session (D13).

### 8.9 Per-user library

`library::all_games()` currently enumerates the SYSTEM host process's HOME — wrong for a profile. When
`profile_target` is set, enumerate **as the target user** (read that user's hive/paths after
`LoadUserProfileW`, or rely on the existing `windows_launch_for` recipes which already run via the user
token once `spawn_in_session(wts)` targets the right session). Visibility curation layers on top via
`profiles.scoped_library(fp, all_games_for_user(sid))`.

### 8.10 Phase ordering & validation matrix

| Phase | Work | Validatable on RTX box (single user)? |
|---|---|---|
| W0 | thread `target_session` through the 3 primitives | YES — target=console = no-op regression |
| W1 | resolve profile→session, occupancy by SID, `profile_target` | Partial — needs a 2nd pre-logged-in account (FUS) |
| W2a | reconnect to existing/disconnected session + supervisor follow + takeover guard | needs 2nd account, signed in once |
| W2b | credential store + `LogonUser`+`LoadUserProfile` | store unit-testable on box; cold logon needs 2nd account |
| W3 | custom Credential Provider (cold-user console auto-login) | needs 2nd account + a display; heaviest; **deferred** |
| W4 | RDS multi-session | Server SKU / RDS host (out of lab) |

Back-compat: absent `profiles.json` or operator/default → `profile_target = None` → `target = active
console` → byte-identical to today at every primitive.

---

## 9. Web console & management API

Operator-facing config surface. Adds a `profiles` tag to the Axum mgmt API
(`crates/punktfunk-host/src/mgmt.rs`), an OS-account enumeration endpoint, the Live-sessions
observability endpoints, and a new web console **Profiles** page following the
`index.tsx`/`view.tsx`/stories/fixtures/i18n conventions. Built against the §4 data model exactly — it
does **not** redefine those types.

### 9.1 `MgmtState` wiring

```rust
struct MgmtState {
    app: Arc<AppState>,
    native: Option<Arc<crate::native_pairing::NativePairing>>,
    profiles: Arc<crate::profiles::Profiles>,  // NEW — always Some; absent file => empty store => today's behavior
    stats: Arc<crate::stats_recorder::StatsRecorder>,
    token: Option<String>, port: u16,
}
```

`run()`/`app()` take an added `profiles: Arc<Profiles>` threaded into the struct literal
(`mgmt.rs:120-126`) — the **same `Arc`** the `punktfunk1` accept loop holds (one source of truth).
Unlike `native` (which can be `None` for a GameStream-only host), `profiles` is always present.

### 9.2 Endpoint inventory + auth

All routes `nest` under `/api/v1` via `routes!()` in `api_router_parts()` (`mgmt.rs:143-176`). New tag
`profiles`. **All mutations + OS-account + sessions are bearer-only** (default deny via `require_auth`,
`mgmt.rs:473-507`). Exactly **one** route joins the streaming-cert allowlist `cert_may_access`
(`mgmt.rs:514-528`): `GET /api/v1/profiles/enumerate`.

| Method & path | operation_id | Auth | Body → Returns |
|---|---|---|---|
| `GET /api/v1/profiles` | `listProfiles` | bearer | → `Vec<ProfileAdmin>` |
| `POST /api/v1/profiles` | `createProfile` | bearer | `ProfileInput` → `ProfileAdmin` (201) |
| `GET /api/v1/profiles/{id}` | `getProfile` | bearer | → `ProfileAdmin` |
| `PUT /api/v1/profiles/{id}` | `updateProfile` | bearer | `ProfileInput` → `ProfileAdmin` |
| `DELETE /api/v1/profiles/{id}` | `deleteProfile` | bearer | → 204 |
| `POST /api/v1/profiles/{id}/passcode` | `setProfilePasscode` | bearer | `SetPasscode` → 204 |
| `POST /api/v1/profiles/{id}/devices` | `assignProfileDevice` | bearer | `AssignDevice` → 204 |
| `DELETE /api/v1/profiles/devices/{fp}` | `unassignProfileDevice` | bearer | → 204 |
| `PUT /api/v1/profiles/default` | `setDefaultProfile` | bearer | `SetDefault` → 204 |
| `POST /api/v1/profiles/{id}/windows-credential` | `setWindowsCredential` | bearer (Windows-only) | `{account_name,password,passcode?}` → 204; `DELETE` clears |
| `GET /api/v1/os-accounts` | `listOsAccounts` | bearer | → `Vec<OsAccountCandidate>` |
| `GET /api/v1/sessions` | `listSessions` | bearer | → `Vec<SessionInfo>` |
| `DELETE /api/v1/sessions/{id}` | `terminateSession` | bearer | → 204 |
| `GET /api/v1/profiles/enumerate` | `enumerateProfiles` | **cert** allowlist | → `Vec<ProfilePublic>` (scoped, D9) |

`cert_may_access` extends its `matches!` arm with `"/api/v1/profiles/enumerate"` (GET-only). The
path-match is EXACT-string, so `/profiles`, `/os-accounts`, and `/sessions` are **not** cert-reachable
(deny-by-default). `get_library` (`mgmt.rs:1152`) branches on auth: streaming-cert →
`st.profiles.scoped_library(fp, all_games())`; bearer → `all_games()` (reads the `PeerCertFingerprint`
extension, the same value `require_auth` reads at `mgmt.rs:483`).

### 9.3 DTOs

```rust
/// Admin view — the full profile MINUS the secret. Never exposes the PHC.
#[derive(Serialize, ToSchema)]
struct ProfileAdmin {
    id: String, display_name: String, accent: Option<String>, avatar: Option<String>,
    os_account: OsAccount, assigned_fingerprints: Vec<String>,
    has_passcode: bool,                         // derived from passcode.is_some()
    require_passcode_even_when_assigned: bool, allow_shared_view: bool, tvos_user_ids: Vec<String>,
    library_scope: LibraryScope, custom_entries: Vec<CustomEntry>, session_defaults: SessionDefaults,
    is_default: bool, created_unix: u64, updated_unix: u64,
}
/// Public picker view (cert-authed; no secret, no OS account, no fingerprint list). Field names align
/// to the wire ProfileEntry (snake_case).
#[derive(Serialize, ToSchema)]
struct ProfilePublic {
    id: String, display_name: String, accent: Option<String>, avatar: Option<String>,
    requires_passcode: bool,
    assigned: bool,                             // requesting cert is assigned → frictionless
}
/// One real OS user the operator may bind. MINIMAL + non-secret: never a password/home/group list.
#[derive(Serialize, ToSchema)]
struct OsAccountCandidate {
    username: String, uid: Option<u32>, sid: Option<String>, full_name: Option<String>,
    platform: String,             // "linux" | "windows"
    is_operator: bool, in_use: bool,
}
/// A live profile session (D11).
#[derive(Serialize, ToSchema)]
struct SessionInfo {
    session_id: String, profile_id: String, display_name: String, os_account: OsAccount,
    client_fp: String, device_name: Option<String>, backend: String, started_unix: u64,
}
#[derive(Deserialize, ToSchema)] struct SetPasscode { passcode: Option<String> }   // null clears
#[derive(Deserialize, ToSchema)] struct AssignDevice { fingerprint: String }
#[derive(Deserialize, ToSchema)] struct SetDefault { profile_id: Option<String> }
```

`ProfileInput` (§4.7) carries `os_account: OsAccount` directly; the web client builds it from the
picked `OsAccountCandidate`: `{kind:"linux",username,uid}` / `{kind:"windows",account_name,sid,credential:{source:"none"}}`
/ `{kind:"operator"}`.

### 9.4 OS-account enumeration source (`os_accounts.rs`)

New cfg-split module `crates/punktfunk-host/src/os_accounts.rs`. **Linux:** iterate `getpwent`,
filtering to human accounts (uid == host uid → `is_operator:true`; uid in `[UID_MIN, 65534)` from
`/etc/login.defs`; valid login shell); `full_name` = first GECOS field. **Windows:**
`NetUserEnum(NULL, level=20, FILTER_NORMAL_ACCOUNT)` + `LookupAccountNameW` → SID string;
`username = ".\\<name>"`; domain enumeration out of scope (operator types `DOMAIN\\user` manually).
`is_operator` candidate is always emitted first. `in_use` is computed by joining against
`profiles.list()`.

```rust
async fn list_os_accounts(State(st): State<Arc<MgmtState>>) -> Json<Vec<OsAccountCandidate>> {
    let host_uid = crate::os_accounts::host_uid();
    let mut cands = crate::os_accounts::list_candidates(host_uid);
    let bound: HashSet<String> = st.profiles.list().iter()
        .filter_map(|p| crate::os_accounts::account_key(&p.os_account)).collect();
    for c in &mut cands { c.in_use = bound.contains(crate::os_accounts::candidate_key(c).as_str()); }
    Json(cands)
}
```

### 9.5 Handler bodies (representative)

Thin wrappers over the §4.7 `Profiles` API, identical in shape to the native-pairing handlers. Each
redacts to `ProfileAdmin` (never serializing `PasscodeHash`). The audit line ("Profile administration",
§6.8) is a `tracing::info!(target: "punktfunk::audit", …)` in each mutation, never the passcode.

```rust
async fn set_profile_passcode(State(st): State<Arc<MgmtState>>, Path(id): Path<String>,
    ApiJson(req): ApiJson<SetPasscode>) -> Response {
    let pass = req.passcode.as_deref().map(str::trim).filter(|s| !s.is_empty());
    if let Some(p) = pass {
        if p.len() < 4 || p.len() > 64 { return api_error(StatusCode::BAD_REQUEST, "passcode 4–64 chars"); }
        // D10 entropy floor: a passcode wrapping a Windows credential must be >=6 alphanumeric.
        if st.profiles.wraps_windows_credential(&id) && !is_alnum_min6(p) {
            return api_error(StatusCode::BAD_REQUEST, "credential profiles need a >=6 alphanumeric passcode");
        }
    }
    match st.profiles.set_passcode(&id, pass) {   // re-wraps the credential blob if needed (D10)
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => api_error(StatusCode::NOT_FOUND, "no profile with that id"),
        Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
    }
}

async fn enumerate_profiles(State(st): State<Arc<MgmtState>>, req: Request) -> Json<Vec<ProfilePublic>> {
    let fp = req.extensions().get::<PeerCertFingerprint>().and_then(|p| p.0.clone()).unwrap_or_default();
    Json(st.profiles.enumerate_public(&fp))   // SCOPED roster (D9)
}
```

**Drift gate:** after editing `mgmt.rs`, regenerate `cargo run -p punktfunk-host -- openapi >
api/openapi.json` (CI fails on drift), then `cd web && pnpm codegen` regenerates `web/src/api/gen/
profiles/*` + the model types.

### 9.6 Web Profiles page

Files mirror Pairing/Library: `web/src/routes/profiles.tsx` (4-line route), `sections/Profiles/index.tsx`
(container — orval hooks + mutations), `sections/Profiles/view.tsx` (presentational, `Loadable<T>` +
callbacks), `stories/Profiles.stories.tsx` + fixtures in `stories/lib/fixtures.ts`, a NAV entry
(`{to:"/profiles", icon: UserCog, label: () => m.nav_profiles()}` in `app-shell.tsx:21-28`), i18n keys in
`messages/{en,de}.json`. `pnpm codegen` emits hooks (`useListProfiles`, `useCreateProfile`,
`useUpdateProfile`, `useDeleteProfile`, `useSetProfilePasscode`, `useAssignProfileDevice`,
`useUnassignProfileDevice`, `useSetDefaultProfile`, `useListOsAccounts`, `useEnumerateProfiles`,
`useListSessions`, `useTerminateSession`).

```tsx
export interface ProfilesViewProps {
  profiles: Loadable<ProfileAdmin[]>;
  osAccounts: Loadable<OsAccountCandidate[]>;
  clients: Loadable<NativeClient[]>;            // paired devices, for the assign dropdown
  sessions: Loadable<SessionInfo[]>;            // Live sessions card (D11)
  onCreate: (data: ProfileInput) => Promise<unknown>;
  onUpdate: (id: string, data: ProfileInput) => Promise<unknown>;
  onDelete: (id: string) => Promise<unknown>;
  onSetPasscode: (id: string, passcode: string | null) => Promise<unknown>;
  onAssign: (id: string, fingerprint: string) => Promise<unknown>;
  onUnassign: (fingerprint: string) => Promise<unknown>;
  onSetDefault: (id: string | null) => Promise<unknown>;
  onReclaim: (sessionId: string) => Promise<unknown>;     // DELETE /api/v1/sessions/{id}
  isSaving: boolean; isDeleting: boolean;
}
```

**Layout** (one `<Section>`): (1) header `h1` + "Add profile"; (2) profile-tile grid (avatar tinted by
`accent`, monogram fallback; OS-account chip; `has_passcode` lock badge; `is_default` star;
assigned-device count; Edit/Delete on hover; "Make default" row action); (3) create/edit form (gated by
`editing: string|null`) with `display_name`, `accent`, `avatar`, an **OS-account `<select>`** populated
from `osAccounts.data` (first option "Operator (this host — shared desktop)" → `{kind:"operator"}`; a
free-text "Other (DOMAIN\\user)…" row for Windows domain accounts), a separate **passcode sub-form**
(routes through `set_passcode`; a brand-new profile creates first then reveals the sub-form),
**LibraryScope** radio (`All|Allow|Deny` + a `store:id` textarea in v1), **SessionDefaults** numeric
inputs; (4) **Device assignment** subsection (the profile's `assigned_fingerprints` joined against
`clients.data` to render device names, an unassign button, and a dropdown of unassigned paired devices);
(5) **Live sessions card** (D11) listing `sessions.data` with a **Reclaim** action calling
`onReclaim(session_id)`; (6) a **"credential not set"** badge on a `CredentialRef::None` Windows profile
("sign this user in manually or add a credential", Critic 2 minor).

**Pairing integration:** `PendingDevicesCard` (`Pairing/view.tsx:111-179`) gains an inline profile
`<select>` per pending row; on Approve, the container chains `approve.mutateAsync(...)` → if a
`profileId` was chosen, `assign.mutateAsync({id: profileId, data: {fingerprint: approvedClient.fingerprint}})`
(the approve response is a `NativeClient` carrying the fingerprint). New keys
`pairing_pending_assign_label`/`_none`.

**GameStream exclusion (UI copy):** a muted note `profiles_gamestream_note` on the page header:
"Profiles apply to native punktfunk connections only. Moonlight/GameStream sessions always use the
operator account and the global library." Also in the `profiles` tag `description`.

**Back-compat:** absent `profiles.json` → `list_profiles` returns `[]` → the page shows the
implicit-operator pseudo-tile + an empty state; `os-accounts`/`sessions` are additive; the `get_library`
scoping branch is a no-op for bearer callers.

---

## 10. Apple clients (macOS / iOS / iPadOS)

Connect-time profile selection for non-tvOS Apple clients (tvOS adds the get-current-user layer in §11
but reuses these views). Built against the **reconciled** model: the profile id rides the Hello, the
passcode rides a never-logged `ProfileUnlock` the connector sends right after — surfaced through
`connect_ex6`; the host rejects a needed/bad passcode **at handshake before building the pipeline**
(one attempt per connection), and the Welcome echoes the resolved id. Enumeration uses the control-plane
`punktfunk_list_profiles` (not the mgmt port — works even when mgmt is loopback-only).

### 10.1 Where it slots vs. trust / pairing

Trust is established **first and unchanged**; the picker is only reachable for an already-pinned host.

| Situation | Behavior |
|---|---|
| Unpinned, `pair=optional` (TOFU), first contact | Connect `profileID = nil`; host resolves the brand-new unassigned fingerprint to its default. No picker (a never-seen device has nothing to pick). |
| Unpinned, `pair=required` | `PairSheet` first; after `handlePaired` pins, the follow-up connect uses `profileID = nil`. |
| **Pinned, default tap** | Connect `profileID = host.lastProfileID` (nil first time) + remembered passcode if any. After `streaming`, cache `connection.resolvedProfile`. |
| **Pinned, "Choose Profile…"** | Present `ProfilePicker` → enumerate → pick → (passcode if challenged) → connect with the explicit selection. |
| **Connect rejected** (`profilePasscodeRequired`/`Wrong`/`profileNotFound`) | `SessionModel` publishes a typed `profilePrompt`; `ContentView` opens `ProfilePicker` pre-seeded to re-prompt. |

First-contact carries the profile id in the *same* Hello that establishes the session — there is no
second Hello after the user confirms trust, so a never-seen (unassigned) device always lands in the
host default; explicit picking is meaningful only once pinned.

### 10.2 `PunktfunkKit/PunktfunkConnection.swift`

New error cases (append to `PunktfunkClientError`): `.profilePasscodeRequired`, `.profilePasscodeWrong`,
`.profileNotFound`. **Reconciled mapping** — wrong **and** missing passcode both arrive as ABI
`AuthRequired = -11`; the client distinguishes by whether it supplied a passcode:

```swift
guard let h = handle else {
    switch status {
    case statusAuthRequired where passcode != nil: throw PunktfunkClientError.profilePasscodeWrong
    case statusAuthRequired:                        throw PunktfunkClientError.profilePasscodeRequired
    case statusNotFound:                            throw PunktfunkClientError.profileNotFound
    default:                                        throw PunktfunkClientError.connectFailed
    }
}
```

`init` gains `profileID: String? = nil` and `passcode: String? = nil` (after `launchID`; defaulted so
existing call sites compile unchanged), replaces `punktfunk_connect_ex5` with `punktfunk_connect_ex6`
(threading `profileID`/`passcode` via `withOptionalCString` + capturing the `status_out` pointer), and
reads `resolvedProfile` via `punktfunk_connection_profile(h, &profBuf, …)`:

```swift
public private(set) var resolvedProfile: String?   // Welcome echo; nil = single-user / old host
```

Enumerate (free function, mirrors `pair()`), now carrying the **`assigned`** flag (D9 — so the picker
renders frictionless profiles without a blind try):

```swift
public struct HostProfile: Identifiable, Hashable, Sendable {
    public let id: String
    public let displayName: String
    public let passcodeRequired: Bool
    public let assigned: Bool         // NEW (D9): requesting cert is assigned → connect on one tap
}
public func listProfiles(host: String, port: UInt16 = 9777, identity: ClientIdentity,
    pinSHA256: Data? = nil, timeoutMs: UInt32 = 10_000) throws -> (profiles: [HostProfile], hostFingerprint: Data)
// over punktfunk_list_profiles → PunktfunkProfile{ char id[65]; char display_name[65];
//                                                  uint8_t passcode_required; uint8_t assigned }
```

### 10.3 `SessionModel.swift`

`connect(to:…)` gains `profileID`/`passcode` (defaulted), passes them into the `PunktfunkConnection(...)`
init inside the existing `Task.detached`, and in the `.failure` arm publishes a structured prompt:

```swift
@Published var profilePrompt: ProfilePrompt?
struct ProfilePrompt: Identifiable, Equatable {
    let id = UUID(); let host: StoredHost; let attemptedProfileID: String?
    enum Reason: Equatable { case choose, passcodeRequired, wrongPasscode, notFound }
    let reason: Reason
}
// .failure(.profilePasscodeRequired) → .passcodeRequired ; .profilePasscodeWrong → .wrongPasscode ;
// .profileNotFound → .notFound ; existing generic errorMessage handling otherwise unchanged.
```

### 10.4 Per-host cache + passcode at rest

`StoredHost` gains `var lastProfileID: String?` (Codable back-compat like `mgmtPort`; seeds the next
connect's `profileID`) + `HostStore.setProfile(_:profileID:)`. New
`PunktfunkClient/ProfilePasscodeStore.swift` — a Keychain store mirroring `ClientIdentityStore`'s
data-protection-keychain + legacy-fallback, keyed by `(host cert fingerprint hex, profileID)`:

```swift
final class ProfilePasscodeStore: @unchecked Sendable {
    static let shared = ProfilePasscodeStore()
    func load(hostFingerprint: Data, profileID: String) -> String?   // nil = not remembered
    func save(_ passcode: String, hostFingerprint: Data, profileID: String)
    func delete(hostFingerprint: Data, profileID: String)
}
```

Default behavior is **not** to remember (shared-device safety); the picker offers an explicit "Remember
on this device" toggle. **Clear on wrong (D12):** a wrong-passcode result clears any cached entry. The
toggle is **forbidden on shared/tvOS builds** (§11, D12). Deleting a host (`HostStore.remove`) /
`forgetIdentity` sweeps the host's stored passcodes.

### 10.5 `ContentView.swift` orchestration + `ProfilePicker.swift`

`connect(_:)` threads `profileOverride: ProfileSelection?`; `defaultSelection(for:)` returns
`(host.lastProfileID, remembered passcode)`; the `streaming` `.onChange` caches
`store.setProfile(host.id, profileID: model.connection?.resolvedProfile)`. A single sheet bound to
`model.profilePrompt` drives `ProfilePicker` (tvOS uses `.navigationDestination(item:)`). A host-card
context-menu callback `chooseProfile(_:)` opens the picker with `reason: .choose`, gated on
`prof=1`/`lastProfileID` + pinned.

`ProfilePicker.swift` (new): a two-step flow — enumerate (off-main via `listProfiles`) → list → on a
passcode-required pick (or `.passcodeRequired`/`.wrongPasscode` re-entry) collect the passcode; returns
`Result?{ profileID, passcode?, remember }` (nil = cancelled). Each row shows `displayName` + a lock
glyph when `passcodeRequired`; an **assigned** or passcode-less profile connects on one tap (try
`passcode = nil` first, re-prompt only on `profilePasscodeRequired`). The passcode step is a `SecureField`
(numberPad on iOS), a default-off "Remember" toggle, an inline "Wrong passcode — try again" on re-entry;
tvOS swaps to `TVFieldRow`/`TVTextEntry` like `PairSheet`.

### 10.6 Settings, HUD, back-compat

Per-profile client settings are **not** needed (the host-side `SessionDefaults` clamps the client's
global request at resolve time + Reconfigure). The HUD adds an optional "Profile: …" line from
`connection.resolvedProfile`. Touchpoints: `PunktfunkConnection.swift`, `SessionModel.swift`,
`HostStore.swift`, new `ProfilePasscodeStore.swift` + `ProfilePicker.swift`, `ContentView.swift`,
`HomeView.swift`, `HostCards.swift`, `HostDiscovery.swift` (expose `DiscoveredHost.supportsProfiles`
from TXT `prof=1`), `StreamHUDView.swift`. Every new param defaults `nil`/`.auto`, so an un-updated
client behaves exactly as today; only `PunktfunkConnection.init` migrates to `ex6`.

---

## 11. tvOS profile binding

Binds the active Apple TV user to a profile. **Extends** §10 (it does not re-spec the connect plumbing);
it adds the tvOS-only "read who's in front of the TV → auto-select their mapped profile" layer. **One
shared Apple TV app identity + one host pairing:** the single device cert is presented by *every* tvOS
user, so the host sees **one fingerprint** for the whole Apple TV; the **profile** (Hello `profile_id`)
plus its passcode separates the humans. `currentUserIdentifier` is a **client-side hint only** (D8).

### 11.1 Mechanism + Apple symbols

- **Framework:** `import TVServices` (tvOS only). **Class:** instantiate `TVUserManager()` and read
  `var currentUserIdentifier: String?` — an opaque, per-app-stable string (not an Apple ID/name/email),
  non-nil only once per-user profiles are enabled **and** the app holds the user-management entitlement;
  `nil` on a single-user device or an un-entitled build.
- **User-changed detection:** the **primary** mechanism is re-reading `currentUserIdentifier` on every
  foreground (`scenePhase == .active`) + at launch (covers the case where the app is not relaunched on a
  switch). A dedicated change notification is a **secondary/defensive** observer if it exists.
- **Entitlement:** `com.apple.developer.user-management = ["get-current-user"]` (read the active user
  only). We deliberately do **not** request `runs-as-current-user*` — those swap the app's data container
  per tvOS user → a separate Keychain identity → a separate host pairing, contradicting "one shared
  identity." `get-current-user` keeps a single shared container. Goes in a **new**
  `Config/Punktfunk-tvOS.entitlements` (split from the shared iOS file, mirroring the macOS split). The
  entitlement is **Apple-gated and not guaranteed** for a streaming app → the whole feature must degrade
  gracefully (§11.4).

> **Open symbols (§14):** the exact `TVUserManager` construction form (`()` vs `.shared`) and whether a
> public `didChangeCurrentUserNotification` exists are **unverified** against the tvOS SDK; the design
> caches one instance and treats the notification as optional so the `scenePhase` re-read alone is
> sufficient.

### 11.2 The mapping store — per-(tvOS user, host) → profile id

```swift
// DefaultsKeys.swift
public static let tvUserProfileMap = "punktfunk.tvUserProfileMap"   // [compositeKey: profileID]
// compositeKey = "<tvUserID>\u{1f}<host UUID>"  (0x1F unit separator — absent from either half)
```

`TVUserProfileBinding` (new, tvOS-only) is a thin typed wrapper over that `[String:String]`
`UserDefaults` dictionary: `profileID(user:host:)`, `setProfileID(_:user:host:)`, `forgetHost(_:)`. **No
passcode is ever persisted here (D12)** — only the chosen profile id, which is non-secret. First-run for
a `(user, host)` pair → present §10's `ProfilePicker(.choose)`; on resolve, persist the binding + connect.
Subsequent connects auto-select silently, re-prompting only for a passcode-protected profile's passcode.
A switch detected mid-session disconnects (the live stream belonged to the previous human).

### 11.3 The identifier is a HINT only (D8)

`currentUserIdentifier` never rides the wire; it selects the `profileID` passed to `connect_ex6`, and the
host resolves by its own D4 table keyed on the **device cert fingerprint** + the profile's passcode
policy. A `tvos_user_ids` match on the host **still requires the profile's passcode** for any protected
profile: the client auto-selects `profileID = P`, sends `passcode = nil` first, and drives the passcode
step on `profilePasscodeRequired`. There is **no `tvos_attested`** flag and no passcode bypass. Because
all tvOS users present the same cert, host-side device assignment cannot differentiate them — so a
passcode-**less** assigned profile is frictionless for *every* user on that Apple TV (the family "shared
TV" profile), and any enforced separation between humans **requires a passcode**.

### 11.4 Remote-friendly passcode + D12 + graceful fallback

Passcode entry reuses §10's `ProfilePicker` (tvOS keyboard via `TVFieldRow`/`TVTextEntry`/`fullScreenCover`).
tvOS rules: **forbid "Remember on this device"** (`ProfilePicker`'s `remember` toggle + every
`ProfilePasscodeStore` call gated `#if !os(tvOS)`; `Result.remember` forced false; the tvOS connect path
never reads/writes the Keychain) — the passcode is a true second factor entered once per session. **Clear
on wrong** is automatic (the failed `connect_ex6` already tore down its connection; one attempt per
connection → the retry is a fresh connect with the freshly-typed code).

**Fallback:** entitlements are signing-time, not compile-time, so the code path is always present. When
the entitlement is absent (or "Users" off, or an iOS/macOS build), `currentUserIdentifier` returns `nil`
⇒ the tvOS connect path falls through to §10's device-global behavior (`host.lastProfileID` + the explicit
"Choose Profile…" item) — the same flow macOS/iOS use, and the **only sound mode** without a per-user
identity. When the entitlement is later approved, the same binary activates the per-user binding with
**zero code change**.

### 11.5 App-lifecycle wiring + scope

`TVUserBinding` (new, tvOS-only `@MainActor ObservableObject` owned by `ContentView`) reads
`TVUserManager().currentUserIdentifier`, republishes on `refresh()`, and returns the previous value so
callers detect a switch. `ContentView` (tvOS): `@StateObject tvUser`, `@Environment(\.scenePhase)`; on
`scenePhase == .active`, `tvUser.refresh()` — if it changed and a session is live, `model.disconnect()`.
`defaultSelection`/`connect` branch on tvOS (mapped user → bound profile, `passcode: nil`; first-run →
`ProfilePicker(.choose)`); the picker-resolution closure persists the binding on tvOS (and skips the
`ProfilePasscodeStore` save/delete). **Keep `HostStore`/`ClientIdentityStore`/`SettingsView`/`DefaultsKeys`
GLOBAL** (one shared identity ⇒ one pairing ⇒ one host list — forced by the decision and right for a
family TV); **only the profile binding is per-user** (non-secret). `SessionModel` stays a single global
`@StateObject` (one Apple TV streams one session).

### 11.6 Host-on-disk implication

`punktfunk1-paired.json` records **one fingerprint** for the whole Apple TV. The operator should: assign
the shared fingerprint to the family/default profile for a passcode-less landing; give any private
family-member profile a **passcode** (device assignment alone won't protect it — the kid's tvOS user
presents the identical cert); treat `tvos_user_ids` as advisory console metadata only (never a trust
input, never relaxes the passcode).

---

## 12. Lifecycle & edge cases

A consolidated, normative list of the lifecycle behaviors the implementation must honor (all from D11
unless noted).

### 12.1 Orphan-session reaping

- **Linux:** the broker treats the zone-1 control socket as each session's **lifeline** — on peer death
  (host crash / console-change restart / OOM), every `SessionRec` opened on that connection is reaped
  (`pam_close_session` + `kill` + `waitpid`). Redundant nets: `PR_SET_PDEATHSIG=SIGKILL`, worker
  self-exit on `session_fd` EOF, and a broker-startup sweep of `/run/punktfunk/sessions/*.pid` (§7.6).
- **Windows:** session teardown is symmetric to setup — `UnloadUserProfileW` + `CloseHandle(token)` +
  release occupancy (§8.7). No leaked hives/tokens.

### 12.2 `delete()` / `unassign()` side effects

- **`delete(profile)`:** terminate-or-refuse active sessions running as that profile (operator choice;
  surfaced through `DELETE /api/v1/sessions/{id}`), clear `default_profile_id` if it pointed here,
  `profile_cred::clear` (Windows blob), drop the `PasscodeGate` entries, release occupancy.
- **`unassign_device`/reassign mid-session:** the **existing** session continues; the change re-resolves
  on the **next** connect (documented policy — no mid-session eviction).

### 12.3 Passcode-change re-wrap (D10)

`set_passcode` on a passcode-wrapped **Windows** profile must **re-wrap** (decrypt-old → encrypt-new) or
surface a "credential needs re-entry" state — otherwise the blob stays under the OLD passcode and cold
logon silently fails. `update(os_account)` clears the credential blob + the resolved uid/SID (the blob's
inner account would otherwise point at the wrong user).

### 12.4 Reconfigure SessionDefaults re-clamp

The mid-stream `Reconfigure` path re-intersects the requested mode/bitrate with the resolved profile's
`SessionDefaults` (not just the initial Hello), so a client cannot renegotiate past the profile's policy
(§7.6 for the brokered worker; the Operator path clamps in-process).

### 12.5 Occupancy reclaim

A second device to an occupied uid/SID → `Occupied`; the operator sees "occupied by <device>" on the
Live-sessions card and can **Reclaim** (`DELETE /api/v1/sessions/{id}` → release occupancy + broker
`CloseSession` / `UnloadUserProfile`). A same-device-same-profile reconnect preempts the prior session.

### 12.6 `profiles.json` consistency (D11)

`profiles.json` is the single on-disk source of truth both zone 1 and the broker read. A **failed
`save()` is a failed mutation** — return an error, don't keep the optimistic in-memory change (else the
broker, which re-stat/reloads per `OpenSession`, would resolve against stale data — e.g. a just-deleted
profile). A parse failure keeps raw bytes and refuses mutations.

### 12.7 Provisioning preflight (Linux render/video/input)

The zone-2 worker runs capture+encode + gamepad injection **as the target uid**, so that user needs
`render`/`video` (NVENC/VAAPI on `renderD128`) and `input` (uinput/uhid) group membership. The broker
preflights `getgrouplist` and returns `MissingGroups` → the client sees `SessionUnavailable{LoginFailed}`
naming the missing groups, **not** a black screen.

### 12.8 Cold-user / SessionUnavailable surfacing + client UX

When a profile resolves but the OS session can't be created (Linux PAM/spawn/linger/missing-groups;
broker absent; Windows bad/absent credential, cold user needing the deferred Credential Provider, or a
local-user console held), the host sends a typed `ProfileReject{SessionUnavailable{NoBroker|LoginFailed|
NeedsConsoleSwitch|Busy}}` (ABI `-14`). Every client maps it to a clear message — e.g. "Could not sign in
<profile> on the host — the account may need a one-time manual login / credential setup" — **never** a
generic `connectFailed` and **never** a silent fallthrough to the operator desktop. `NeedsIsolation`
(`-15`) is reserved for "the box can't isolate this profile at all" (no gamescope), no longer overloaded
for credential/cold-user failures.

---

## 13. Rollout, phasing, back-compat & testing

Normative ordering — where an upstream section implies a different build order, **this wins**.

### 13.1 Sequencing principles + the one inviolable safety rule

1. **Privilege last.** Every increment that does *not* become another OS user ships and validates before
   any privileged code (Linux root broker / Windows SYSTEM logon). A bug in Phases 0–1 cannot escalate.
2. **Lowest-risk / most-reusable first.** data model → wire/ABI → privileged Linux → privileged Windows →
   tvOS. The D2/D3/D4 foundation types are frozen in Phase 0.
3. **Every phase is independently shippable + on-by-default-safe.** At every boundary, a host with no
   `profiles.json` is byte-identical to today.

> **THE SAFETY RULE — SEC-3 fail-closed ships WITH resolution, not with the broker (D5).** Profile
> *resolution* (Phase 1) and the *isolating worker* (Phase 2) land in different increments. That gap is
> the catastrophic-leak window. Therefore the fail-closed gate is part of the **Phase-1** deliverable:
> at resolve time, `os_account != Operator` && isolating worker absent → reject
> `SessionUnavailable{NoBroker}`/`NeedsIsolation` **before any pipeline is built**. The only code path
> consuming a non-`Operator` `ResolvedProfile` is the broker client, which does not exist until Phase 2;
> until then the non-`Operator` arm dead-ends in the reject. This is what makes "ship Phase 0/1 before the
> broker" safe.

### 13.2 Back-compat contract

The wire uses append-only trailing-field decoding (Hello after `video_caps`, Welcome after `color`);
`ABI_VERSION` stays `2`; no flag-day (full matrix in §5.8). Default-profile semantics are the back-compat
anchor: absent `profiles.json` → every device resolves to the implicit operator profile → today's
behavior byte-for-byte; an unassigned device with no `profile_id` resolves to the operator default
**only when the default has no passcode** (D4 — closes the default-passcode bypass).

**Feature gate (the "installing this turns my box into a root-broker attack surface" worry).** No
`profiles.json`, or only `Operator` profiles → zero privileged code ever executes (the broker is the
*only* caller of the non-`Operator` arm; socket-activated, never spawns otherwise). The broker is a
**separate, optional package** (`Recommends:`/`Suggests:`, not a hard dependency); a minimal/Flatpak
install has no root component on disk → Operator-only by construction, real-user profiles fail closed
with `SessionUnavailable{NoBroker}`.

### 13.3 Phased delivery

| Phase | Scope | Privilege | Gate |
|---|---|---|---|
| **0** | `profiles.rs` schema-of-record + Argon2 verifier + D4 resolver + `PasscodeGate` + validations (D5/D6/D10) + mgmt CRUD + `os_accounts.rs` + web Profiles page + GameStream-exclusion copy + `scoped_library`. | none | standard review |
| **1** | Wire (`Hello.profile_id`, `Welcome.resolved_profile`, `ListProfiles`/`ProfileList`/`ProfileUnlock`/`ProfileReject`) + `connect_ex6` + status codes + `NativeClient` + mDNS `prof=1` + **the SEC-3 fail-closed gate** + Reconfigure re-clamp + Apple/Linux client plumbing + REST enumerate. **All resolve to the Operator session; non-Operator FAILS CLOSED.** | none | none, but the safety rule is *proven* (non-Operator must be observed to reject, not stream) |
| **2 — Linux** | PAM/logind **headless spike first** → `punktfunk-broker` crate + root service/socket (caps trimmed) + `/etc/pam.d/punktfunk` + per-uid worker (SEC-1) + SEC-3 routes through the broker + occupancy by uid + orphan reaping + `GET/DELETE /api/v1/sessions` + render/video/input preflight + zone-0 audit. | **PRIVILEGED** | **explicit user go/no-go before merging privileged code** |
| **3 — Windows** | W0→W2b (target-session generalization, SID occupancy, FUS reconnect + takeover guard, DPAPI cred store + TPM-bind + entropy floor, hive/token unload, set_passcode re-wrap, lockout/occupancy persistence, windows-credential route + dead-state badge). **Credential Provider (cold console) deferred.** Front-door SYSTEM-vs-non-SYSTEM is the gate decision (D7). | **PRIVILEGED** | **explicit user go/no-go**; record the SYSTEM-v1 invariant-violation + trusted-LAN posture |
| **4 — tvOS** | get-current-user binding → client-side auto-selection only (D8); reconnect on switch; Remember forbidden on tvOS. | none | none (entitlement may be denied → degraded mode is default-safe) |

Phases 2 and 3 are independent (different platforms/boxes) and may proceed in parallel; each needs its
own gate. Phase 4 only needs the Phase-1 wire/ABI.

### 13.4 Test strategy (cross-cutting)

- **Unit (`profiles.rs`):** the **D4 authority table, one case per row** (incl. the
  `None`+unassigned+passcode-default = require regression, and `require_passcode_even_when_assigned`
  upgrading each grant); Argon2id PHC round-trip + decoy-verify constant cost + `(profile_id,client_fp)`
  keying (one device's lockout does NOT lock another or the profile globally) + backoff/lockout schedule;
  load/save atomicity + **failed-save-is-failed-mutation** + parse-failure-refuses-mutation + load-time
  dedupe + **same-uid/SID reject (D6)** + compositor + entropy-floor validation.
- **Core round-trip:** Hello/Welcome new trailing fields decode to `None` from old bytes; placeholder
  offsets deterministic; `ListProfiles`/`ProfileList`/`ProfileUnlock`/`ProfileReject` encode/decode;
  `Welcome.resolved_profile` carries the **id**; `connect_ex6` ABI harness (C round-trip, `ex5`→`ex6`
  `NULL,NULL,NULL` delegation, status codes).
- **Loopback (QEMU VM):** probe `--profile <id>` happy (assigned/no-passcode, correct passcode) and
  `--passcode 0000` → `ProfileReject{WrongPasscode}`; wrong ×N → `LockedOut`, confirm a **second
  `client_fp` is NOT locked**; a `Linux` profile → **MUST reject `SessionUnavailable{NoBroker}`** (the
  safety rule). Phase 2 adds the real-uid worker over loopback.
- **Privilege/PAM headless spike — on the QEMU VM, FIRST in Phase 2** (gate-before-build): prove
  root→PAM→setuid→gamescope-as-uid headless (`enable-linger`, PAM items, session class,
  `pam_close_session` across a worker crash, render/video/input membership, MDWX compatibility).
- **Real OS-user e2e:** QEMU VM (2nd user, isolation + reaping + leak test); GNOME box (2nd user +
  operator-multi-occupant coexistence); Steam Deck (gamescope game-mode cold login); AMD Sway (VAAPI
  worker); RTX Windows box with a 2nd account `pf-test` (FUS reconnect, console switch, SID occupancy,
  restart-persistence, local-user-not-evicted).
- **Security regression list (must stay green):** default-profile passcode no-bypass (D4); occupancy by
  uid/SID, Operator exempt (D6); orphan reaping (D11); SEC-3 impossible-state (D5); passcode never
  logged + never in Hello (D1, grep-assert); rate-limit keying (D1); Windows restart-persistence (D1/D6);
  SEC-2 (broker refuses an absent id / a wire uid); GameStream pinned to operator (D13); tvOS match still
  requires passcode (D8).
- **Web/CI:** `pnpm typecheck` + Storybook (Profiles page, Live-sessions card); **OpenAPI drift gate**;
  `cargo clippy --workspace --all-targets -- -D warnings` + `cargo fmt --all --check` every phase;
  `swift build`/`swift test` on `home-mac-mini-1` (Phases 1, 4).

### 13.5 Packaging / migration

- **Linux broker package** (`deb.yml`/`rpm.yml` + COPR/Arch/sysext/bootc): separate
  `punktfunk-session-broker` package (broker binary + `.service`/`.socket` + `/etc/pam.d/punktfunk` +
  tmpfiles), `Recommends:`/`Suggests:` of `punktfunk-host` (not a hard dep).
- **Flatpak / sandboxed:** `Operator`-profile-only; ships a capability flag advertising
  `profiles=operator-only`; real-user profile → `SessionUnavailable{NoBroker}`, never silent fallthrough.
- **Windows:** `windows-host.yml` Inno installer bundles the (future) SYSTEM broker + cred store +
  (deferred) Credential Provider; the front-door service principal (SYSTEM vs non-SYSTEM) is set by the
  installer per the Phase-3 gate.
- **Migration:** absent `profiles.json` = today's behavior; `version:0`/missing → `1`; additive fields
  `serde(default)`; a breaking change bumps `PROFILES_SCHEMA_VERSION` with a one-shot `load()` migration.

### 13.6 Docs

- This document (`design/multi-user-profiles.md`) is the SoT, including the headless-spike write-up and
  the per-platform honest-scope statement.
- **Supersede `design/gamescope-multiuser.md`** (mark it superseded here — the per-uid worker moves the
  EIS/PipeWire sockets out of shared `/tmp` into the user's runtime dir; the old variant must not ship).
- `docs-site/content/docs`: move "Magic multi-user support" to **In-progress**, fold in "gamescope
  multi-user isolation," update `status.md` per phase. Add a CLAUDE.md *What's left* §3 bullet
  summarizing the phased state + the D7/D8/D12 honest-scope caveats.

---

## 14. Open questions

Carried forward; none block the foundation (Phase 0/1), several gate later phases.

1. **tvOS Apple symbols / entitlement (HIGH uncertainty, §11.1):** confirm `TVUserManager()` vs
   `.shared`; whether a public `didChangeCurrentUserNotification` exists (if not, the `scenePhase`
   re-read is the sole, sufficient detector); whether `com.apple.developer.user-management` /
   `get-current-user` is grantable to a streaming app (Apple-gated, positioned at games); and that
   `get-current-user` **without** `runs-as-current-user` keeps a single shared data container (else the
   single-pairing decision breaks). The whole feature degrades to the manual picker if denied.
2. **Linux AU-relay throughput at 5K@240** across the `session_fd` SEQPACKET hop is asserted-equal to the
   Windows `wgc_relay` precedent but **not yet Linux-measured** — Phase 1 benchmarks it (`tools/latency-probe`)
   before committing the relay as the only isolated path. Fallback if it regresses: a per-uid QUIC
   terminator in the worker (duplicates the endpoint, removes the hop).
3. **Windows Credential Provider scope (W3):** ship a custom `ICredentialProvider` for true cold-console
   auto-login, or stay on "operator pre-logs-in the user once" indefinitely? Affects whether Windows ever
   delivers the headline cold-login.
4. **Full-desktop Linux session path:** add a path that logs the uid into their **own** KWin/GNOME and
   captures *that* in isolation (vs. the v1 gamescope game-mode session)? Needed if "their own desktop"
   becomes a hard requirement.
5. **Assigned device picking a different profile:** the D4 table allows an assigned device to select a
   *different* profile (subject to that profile's passcode) — confirm this "guest mode on a personal
   device" is desired vs. pinning assigned devices to their assignment.
6. **Broker ↔ `profiles.json` reload (TOCTOU):** the broker `stat`+reloads per `OpenSession` (fresh,
   simple). Confirm sufficient vs. an `inotify` watch; the only race is an operator editing an assignment
   in the millisecond a session opens, which the next connect corrects.
7. **LibraryScope granularity:** id-level only (today) vs. whole-store rules (`Deny{stores:["xbox"]}`) for
   a kids profile; and the v1 scope editor (full library-id multi-select vs. a `store:id` textarea).
8. **Avatar/accent storage:** inline `data:` URL vs. a host-served asset endpoint (size cap?).

---

## 15. Appendix — reconciliations applied from adversarial review

Every blocker and load-bearing major from the three adversarial reviewers is resolved here; the body
reflects the decision silently. This table records what changed and the governing reconciled decision.

| Finding (severity) | Decision taken | Ref |
|---|---|---|
| Passcode transport — three contradictory designs (plaintext-in-Hello / HMAC challenge-response / mid-handshake) **(blocker ×3)** | Hello carries `profile_id` **only**; passcode rides a dedicated **never-logged `ProfileUnlock`** verified host-side (Argon2id). HMAC challenge-response dropped (`argon2` stays out of core). **One attempt per connection; cross-connection `PasscodeGate` keyed `(profile_id, client_fp)`** (no in-handshake backoff vs `HANDSHAKE_TIMEOUT`). | D1 |
| C ABI `connect_ex6` signature — `status_out` present/absent **(blocker)** | Adopt the signature **with `status_out`** + status codes `AuthRequired=-11`/`NotFound=-12`/`Occupied=-13`/`SessionUnavailable=-14`/`NeedsIsolation=-15`; `ex5` delegates `NULL,NULL,NULL`. | D2 |
| `Profile`/`OsAccount` schema divergence (enum vs flat `OsUserRef`; field names) **(blocker)** | §4 `Profile` is the single schema of record; `OsAccount` is the enum; `CredentialRef` gains `DpapiBlob`; fields `assigned_fingerprints`/`default_profile_id`; add `require_passcode_even_when_assigned`/`tvos_user_ids`/`allow_shared_view`. | D3 |
| Resolution of selected-passcode-less-unassigned (admit vs deny) **(blocker)** + default-profile passcode bypass **(major)** | The **safer stance**: passcode-less-unassigned is **denied** (`NotPermitted`) unless `allow_shared_view`; the default-profile passcode is **enforced** (no bypass). One D4 authority table. | D4 |
| Isolation fail-closed gate absent from buildable sections **(blocker)** | SEC-3 ships in the **same deliverable as resolution** (Phase 1); non-Operator + no isolating worker → reject **before** pipeline build. Impossible-state by construction. | D5 |
| Windows privilege model = host-as-root **(blocker)** | Target = mirror the Linux split (non-SYSTEM front-door + tiny SYSTEM broker); v1-SYSTEM **documented** as not satisfying the invariant → trusted-LAN-only posture. | D7 |
| Observability — no live session view/kill **(blocker)** + reclaim occupied **(major)** | Add `GET /api/v1/sessions` + `DELETE /api/v1/sessions/{id}`; web **Live sessions** card + **Reclaim**. | D11 |
| Orphaned OS sessions on zone-1 death **(blocker)** | Broker treats the zone-1 socket as the **session lifeline** → reaps on peer death; `PR_SET_PDEATHSIG`, worker EOF self-exit, startup PID sweep. | D11 |
| Error surfacing for OS-login failures **(blocker)** | New `SessionUnavailable{NoBroker\|LoginFailed\|NeedsConsoleSwitch\|Busy}` + ABI `-14`, mapped in every client; stop overloading `NeedsIsolation`. | D12 |
| `PROFILE_ID_MAX` 32 vs 64 **(major)** | **64** everywhere (matches `char id[65]`). | D3 |
| `ProfileId` format (`p_`+6hex vs 12-hex) **(major)** | **12 lowercase hex** + reserved `"operator"`. | D3 |
| Welcome echo: name vs id **(major)** | Echo the **resolved id** (client needs it to reland; HUD looks up the name). | D2 |
| Argon2 params + crate placement **(major)** | **m=19456, t=2, p=1**, host-side only (`punktfunk-host`, not core). | D1/D10 |
| Picker DTO shape divergence + missing `assigned` **(major)** | One display-safe DTO; `ProfileEntry`/`ProfilePublic`/`PunktfunkProfile` carry **`assigned`** (frictionless without a blind try). | D9 |
| Enumerate transport REST vs control-stream **(major)** | Control-stream `ListProfiles`→`ProfileList` **PRIMARY** for native clients; REST `GET /api/v1/profiles/enumerate` (cert, **scoped**) secondary; `GET /api/v1/profiles` bearer-only. | D9 |
| Assign/unassign route shapes **(major)** | `POST /api/v1/profiles/{id}/devices`, `DELETE /api/v1/profiles/devices/{fp}`. | D9 |
| CredentialRef variants + cred route **(major)** | Add `CredentialRef::DpapiBlob`; add `POST/DELETE /api/v1/profiles/{id}/windows-credential` to the inventory + OpenAPI; web badge for `None` Windows profiles. | D3/D12 |
| tvOS attestation client-asserted/unverifiable **(major)** | Identifier = **UI hint only**; `tvos_user_ids` match **still requires the passcode**; `tvos_attested` dropped. | D8 |
| Passcode rate-limit DoS keying + Windows console-switch wipe **(major)** | Key `(profile_id, client_fp)`; **persist counters in the SYSTEM broker / SYSTEM-DACL file** on Windows. | D1 |
| Offline crack of low-entropy passcode + DPAPI from disk image **(major)** | **≥6 alphanumeric** entropy floor for credential-wrapping passcodes; **TPM/host-identity-bound** wrap KDF; documented disk-image recoverability of passcode-less blobs. | D10 |
| Occupancy keyed by profile_id, not uid/SID **(major)** | Key by **resolved uid/SID**; Operator **exempt**; reject same-uid/SID double-map at load. | D6 |
| Operator profile must stay multi-occupant **(major)** | `OsAccount::Operator` is multi-occupant (preserves `--max-concurrent` multi-view); single-occupancy applies only to real-OS-user profiles (+ Windows global). | D6 |
| PAM/logind headless feasibility **(major)** | Pre-provision with `enable-linger`; set PAM items (`PAM_TTY`/`XDG_SESSION_CLASS=user`/`TYPE`); verify session class; **headless spike gates the broker build**. | D12 / §7.8 |
| Passcode change doesn't re-wrap Windows credential **(major)** | `set_passcode` **re-wraps** or surfaces "credential needs re-entry"; `update(os_account)` clears the blob + resolved SID. | D10 |
| Profile deletion side effects / cross-store cleanup **(major)** | `delete()`: terminate-or-refuse sessions, `profile_cred::clear`, drop `PasscodeGate`, release occupancy; mid-session unassign continues, re-resolves next connect. | D11 |
| Cold-login honesty (Windows v1 / Linux game-mode) **(major)** | Stated per-platform: Linux cold auto-login into a **gamescope game-mode** session; Windows v1 = fast-user-switch (CP deferred). | D12 |
| Packaging broker/PAM + graceful degradation **(major)** | deb/rpm/COPR/Arch/bootc ship the broker (optional pkg); Flatpak **Operator-only** + capability flag; broker-absent real-user profile → `SessionUnavailable{NoBroker}`. | D12 |
| Windows console takeover of a local user **(major)** | Detect local vs remote console; **refuse/opt-in** before evicting a physically-present user. | D12 |
| `require_passcode_even_when_assigned` / `tvos_user_ids` / `allow_shared_view` absent from schema **(minor)** | All three folded into `Profile`/`ProfileInput`/DTOs; `resolve_for_device` honors the override. | D3 |
| SEC-3 vs free `SessionDefaults.compositor` **(minor)** | Validate at create/update — non-Operator may not set a shared-desktop compositor. | D5 |
| Zone-1 RCE input-injection residual **(minor)** | Documented residual R1; worker owns + rate-limits the input grant. | §6.8 |
| Broker capability surface contradicts "tiny TCB" **(minor)** | Caps trimmed to `CAP_SETUID/SETGID/SETPCAP/KILL`; `CAP_SYS_ADMIN`/`CAP_DAC_OVERRIDE` dropped; MDWX re-verified. | D7 |
| Enumerate leaks full roster **(minor)** | Scope to the requester's assigned + passcode-protected picker-visible profiles. | D9 |
| LibraryScope marketed as enforcement **(minor)** | Documented as presentation curation, not a sandbox; launch-by-id scoped. | D12 |
| PAM passwordless file is a standing hazard **(minor)** | Documented broker-only + security-sensitive; `pam_succeed_if` to mapped accounts; keep `account pam_unix`. | §6.8 / §7.1 |
| Windows hive/token not unloaded **(minor)** | `UnloadUserProfileW` + `CloseHandle(token)` on teardown. | D11 |
| No UI to provision Windows credential **(minor)** | windows-credential route + "credential not set" badge. | D12 |
| Reconfigure doesn't re-clamp SessionDefaults **(minor)** | Reconfigure re-intersects with the resolved profile's `SessionDefaults`. | D11 |
| Broker vs zone-1 `profiles.json` consistency **(minor)** | Single on-disk source of truth; failed save = failed mutation; broker re-stat/reloads per `OpenSession`. | D11 |
| Stale cached passcode + remember on shared/tvOS **(minor)** | Clear cache on wrong result; **forbid "Remember"** on shared/tvOS builds. | D12 |
