diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2fec043..362d163 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,13 +8,13 @@ List automated tests, manual checks, environments, and meaningful measurements. ## Research And Decisions -Link the relevant requirement, research note, issue, and ADR, or explain why none is needed. +Link the relevant product owner, research note, issue, and ADR, or explain why none is needed. ## Checklist - [ ] The branch contains one coherent change and no unrelated churn. - [ ] Required local tests and repository hygiene checks pass, and their exact evidence is listed above. - [ ] Security, privacy, compatibility, and licensing effects were considered. -- [ ] Requirements, operational docs, ADRs, project memory, and status were updated where their facts changed. +- [ ] Product owners, operational docs, ADRs, project memory, and status were updated only where their facts changed. - [ ] Stale or conflicting memory and documentation were removed rather than preserved as a diary. - [ ] No credentials, personal data, machine-specific paths, or required untracked artifacts are included. diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index 0dc0e33..8ea5660 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -1,6 +1,7 @@ name: CI on: + pull_request: push: branches: - main @@ -20,7 +21,7 @@ jobs: - name: Validate tracked project state run: sh scripts/check-project-state.sh env: - SCREENER_BASE_SHA: ${{ github.event.before }} + SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/AGENTS.md b/AGENTS.md index d91fb71..f6a69fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,84 @@ # Project Instructions -## 1. Decision And Durable Truth +## Authority And Scope -- Every task and action in this repository must follow the complete repo-tracked [`stop-that-shit` skill](./.agents/skills/stop-that-shit/SKILL.md). Read it before work; that file is the Screener authority for generic task boundaries, and any installed copy must stay synchronized with it rather than becoming a divergent variant. -- Core summary: within higher-priority instructions, the latest explicit user decision defines the task; take only requested actions and proven necessary consequences, and do not treat review/research/implementation as authority for implementation/deployment/cleanup beyond its task type. Broad words such as improve or optimize add no unnamed scope. Prefer standards and one owning invariant over example-specific mechanisms, while preserving unrelated work and necessary callers/tests/security. Reconstruct each deliverable from the accepted current result so it stands alone without rejected alternatives, process narration, or modification traces; inspect only the pending changes and report the result with completion evidence. The full skill governs when this summary is incomplete. -- Model recovery and failure handling from the smallest underlying authority and connection state. A concrete cause such as a graceful restart, crash, timeout, or network loss may improve diagnostics only when it changes a reachable decision; it must not create a parallel state, retry loop, fallback, or user-facing branch when the required recovery action is the same. -- Before a material product-contract, route-model, security, infrastructure, or irreversible change, settle the meaning against the whole product model and evidence, challenge contradictions or needless complexity, and update its single owning truth document. An ordinary scoped implementation or bug fix does not require a truth checkpoint unless it actually changes durable semantics or a current production/execution snapshot. -- Until the owner explicitly declares a public release, maintain only the current internal product contract. Do not preserve backward or forward compatibility, legacy wire/config aliases, dual parsers or writers, translators, deprecated APIs, migration shims, or tests whose only consumer is an older release. Upgrade the private deployment atomically, reject stale clients before authority, delete replaced surfaces, and use Git history for rollback. Reassess compatibility only when public release requirements are explicitly opened. -- Use the lean path by default for ordinary scoped work: inspect the affected code and owner, make the smallest change, run focused checks, inspect the diff, stage the requested files, and report. Do not automatically expand a small change into broad research, multiple truth-document edits, a full test suite, independent reviews, PR/merge, deployment, worktree cleanup, or a release postflight. Use the full integration/release path only when the owner explicitly asks for it or when closing a major phase or changing a public contract, route/security model, infrastructure, persistent/irreversible state, or release boundary. -- When durable semantics do change, update the single actual owner among requirement, design, ADR or research, and update `docs/project-memory.md` or `docs/status.md` only when its snapshot materially changes. Do not create parallel owners or mirror source details across documents. Ordinary UI details, self-evident code behavior, one-off fixes, routine validation, and completed history stay in code, tests, pull requests, and Git. -- If semantics remain disputed, record the hold in `docs/todo.md` and freeze only dependent work. Do not encode a guess as accepted truth. -- Queue new observations in their owner and continue the active milestone unless the user requests immediate investigation or the evidence reveals a P0 blocker. +- Read and follow the complete repo-tracked + [`stop-that-shit` skill](./.agents/skills/stop-that-shit/SKILL.md) before work. + It is the generic authority for scope, smallest-correct-result, and clean + delivery; installed copies must remain synchronized with it. +- Within higher-priority instructions, the latest explicit user decision defines + the task. Review, research, implementation, deployment, and cleanup grant only + the authority inherent in that task type. +- Model recovery from the smallest underlying authority and connection state. + Restart, crash, timeout, and network loss do not justify parallel states or + case-specific fallbacks when their required action is the same. +- Ordinary scoped work uses the lean path: inspect the affected owner and code, + make the smallest coherent change, run focused checks, inspect the diff, and + stage the requested files. Use the full integration/release path only for an + explicitly requested release or a material contract, route/security, + infrastructure, persistence, or irreversible change. +- Until a public release is explicitly declared, keep one current internal + contract. Delete replaced wire/config/API surfaces; do not add compatibility + aliases, dual readers/writers, migrations, or tests for stale private clients. -## 2. Safety And Privacy +## Product Contract -- Never commit credentials, TLS private keys, access tokens, or real user data. Commit only placeholder configuration and document local secret injection. -- Server-assisted media credentials are short-lived and narrowly authorized; rooms use authentication or unguessable expiring invitations. Do not log or persist private media-path identifiers beyond their accepted owner. -- Block the current phase on P0 security, privacy, authorization, irreversible-data, generation, bounded-resource, or rollback failures and on P1 failures of its core path. +- Screener is private game screen sharing for one Host and up to 20 authenticated + Viewers, with Web Host/Viewer/relay as the current product surface. +- Current product truth is split by owner: + [rooms/access](./docs/product/rooms-access.md), + [routing/transport](./docs/product/routing-transport.md), + [media quality](./docs/product/media-quality.md), and + [presentation/lifecycle](./docs/product/presentation-lifecycle.md). +- Media stays automatic and P2P-first; central services provide room authority, + signaling, STUN, observability, and bounded LiveKit SFU/UDP fallback. Route + changes must preserve the single-graph, single-operation model in + [ADR-0005](./docs/adr/0005-automatic-hybrid-media-routing.md). +- WebRTC and LiveKit own network and media adaptation. Do not add custom quality + scores, ladders, all-pairs probes, periodic rebalancing, or hand-built SFU + representations without a new accepted decision backed by primary evidence. -## 3. Product Contract +## Durable Truth -- Build private, low-latency game screen sharing for one broadcaster and up to `20` authenticated Viewers, not a public or large-scale streaming service. -- A normal desktop or mobile browser is the initial Viewer target. A packaged sender or native capture helper is a later optimization. -- Keep media distributed and automatic. Central services own rooms, authentication, signaling, STUN, observability, and bounded fallback resources; do not silently make the product always-SFU. -- Ordinary peer ICE remains STUN-only and direct/peer UDP remains first; the only application fallback is the dedicated LiveKit SFU over UDP. Browser SFU publisher and subscriber PCs retain LiveKit-signaled UDP candidates but do not inherit external STUN/TURN ICE servers; deployment STUN remains available to ordinary peers and LiveKit server-side public-IP discovery. Screener configures no TURN, ICE/TCP, media TCP, or TLS-relayed media path. Every accepted path ends in bounded success or clear failure, and HTTPS/WSS transport is independent of media transport. -- Every non-server endpoint uses one server-authoritative steady outbound media-copy cap: default `2`, configurable as `1`, `2`, or `3`. A peer child or the Host's single SFU publication consumes one slot; upstream receive is free, and SFU subscriber egress is accounted at the server. Browser role, UA, visibility, and client advertisement do not create another tier. -- The accepted route model is one committed graph, one event-driven reconciliation loop, and at most one room-serial child operation. That operation owns one deterministic candidate list/cursor, one current candidate with its reservations, one route-demand owner, and one total deadline; its exact media child commits on the first newly decoded frame. Exact direct transport-connected progress may keep the current candidate until that total deadline but cannot commit or change the graph. Join/waiting, child reparent, relay ingress repair with subtree retention, confirmed departure, and effective-capacity reduction all use this operation. Initial acquisition gives direct candidates one bounded foreground window, then uses SFU for availability while any remaining direct candidates converge behind working media; a failed exact candidate is tried only for that operation and creates no persistent parent blacklist. -- WebRTC and LiveKit own ICE/DTLS/consent, congestion control, transient reconnect, and SFU stream state. Screener does not add all-pairs endpoint probing, parent-wide quality inference, a weighted route score, periodic rebalancing, or an independent depth cap. SFU is one Host publication with per-Viewer subscriptions, and endpoint overlap plus SFU resources remain independently admitted and bounded. Any future strict-firewall transport must be accepted as a LiveKit-internal capability from real evidence, not added as another application route candidate. -- WebRTC and LiveKit also own media-quality adaptation, bandwidth estimation, representation/layer selection, and recovery. Screener may express standard content intent, user-selected ceilings, codec choice, and documented component options, but it does not invent or maintain an application resolution/FPS/bitrate ladder, representation formula, quality score, hysteresis loop, periodic media controller, or manual SFU selector. Browser game-screen video uses the standard `contentHint = "motion"`; do not silently unset it or replace native adaptation unless the owner explicitly accepts a primary-source-supported mature alternative. Prefer pinned component defaults over hand-built SFU layers. +- Start substantial work by reading [project memory](./docs/project-memory.md), + [status](./docs/status.md), [TODO](./docs/todo.md), and only the relevant + product module, ADR, research, or operations document. Then inspect branch, + HEAD, status, staged diff, and worktrees. +- Product modules own current behavior; ADRs own non-obvious decisions and + consequences; research owns evidence and license boundaries; deployment owns + operational reference. Status is the current execution/deployment index, TODO + is the only work ledger, and Git/PRs own completed history. +- Give every durable fact one owner. Update memory or status only when its compact + snapshot materially changes. Ordinary UI detail, protocol field listings, + self-evident code, test inventories, routine validation, and agent process do + not belong in long-lived product truth. +- If semantics remain disputed, record the hold in TODO and freeze only dependent + work. Never encode a guess as accepted truth. -## 4. Canonical Repository And Integration +## Safety -- Keep the canonical repository root on a clean, current `main` at audit and integration boundaries. Create branches and worktrees from that exact commit; an auxiliary worktree or old branch is never a truth source. -- Merge an accepted truth checkpoint before integrating dependent candidates. Rebase or rebuild each retained candidate from that exact `main` once, preserve main's owning truth on conflicts, and transplant only approved scoped code, tests, and new facts. -- Routine application-only releases verify a new immutable artifact, switch to it atomically, and guarantee the pre-cutover application release only through bounded health and postflight checks; it has no retention contract afterward and is not a maintained backup. A task that changes infrastructure, configuration, secrets, persistent state, or an irreversible surface must define recovery for only those touched surfaces before changing them. -- Cleanup is last. Preserve user work and verify integration, open references, branch equivalence, and link/reparse safety before removing a worktree or branch. -- Follow `CONTRIBUTING.md` for branch, research, validation, PR, merge, cleanup, and local-hook details. +- Never commit credentials, TLS private keys, raw access tokens, or real user + data. Commit placeholders and document local secret injection. +- Keep media credentials short-lived and narrowly authorized. Do not log or + persist private media-path identifiers beyond their accepted owner. +- Block the current phase on P0 security, privacy, authorization, irreversible- + data, generation, bounded-resource, or rollback failure, and on P1 failure of + that phase's core path. -## 5. Truth Map +## Repository And Delivery -- Start substantial work by reading `docs/project-memory.md`, `docs/status.md`, `docs/todo.md`, the relevant requirement/design, ADR, and research documents, then inspect the current branch and Git state. -- Current requirements and design live in `docs/需求理解.md` and `docs/方案设计.md`; decisions live in `docs/adr/`; evidence lives in `docs/research/`. -- `docs/project-memory.md` is the durable product snapshot, `docs/status.md` is the current execution/deployment index, and `docs/todo.md` is the only current work ledger. Git history owns the completed timeline. -- Give each durable fact one owner and replace stale text in place. Long-lived documents record product contracts, non-obvious invariants, algorithms/models, key tradeoffs, external evidence, and current execution boundaries; ordinary UI, self-evident implementation, routine validation process, and completed history stay in code, tests, pull requests, and Git. Keep status as an index rather than an archive, and update `docs/README.md` when documents move or change status. +- Canonical root must be clean current `main` at audit/integration boundaries. + Create branches/worktrees from that exact commit; old branches and worktrees + are never truth sources. +- Preserve user work. Branch, truth-checkpoint, PR, release, recovery, and + cleanup rules are owned by [CONTRIBUTING.md](./CONTRIBUTING.md); do not mirror + that workflow here. -## 6. Engineering Defaults +## Engineering Defaults -- Research non-trivial design, implementation, and bug fixes from current primary sources. Record durable findings and license boundaries under `docs/research/`. -- Add focused tests in proportion to the changed risk. Full-suite tests, independent review, expensive browser/network/endurance checks, and deployment validation belong at an explicitly requested or major acceptance boundary; use reproducible WebRTC measurements for performance claims. -- Keep scripts and hooks deterministic, fast, cross-platform, and CI-runnable. -- Support Windows, macOS, and Linux; avoid absolute paths and OS-specific separators. Text files use LF, and new filenames use ASCII unless an established user-facing convention requires otherwise. -- Before finishing material work, inspect Git status, report untracked project artifacts, and stage requested files unless told otherwise. +- Use primary sources for non-trivial design and bugs. Add focused tests in + proportion to risk; batch full browser/network/endurance checks at acceptance. +- Keep scripts deterministic, fast, cross-platform, and CI-runnable. Support + Windows, macOS, and Linux; use LF and ASCII filenames for new files. +- Before finishing material work, inspect status, report untracked artifacts, + and stage requested files unless told otherwise. diff --git a/README.md b/README.md index 20c4675..3ad1da9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # Screener -Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its accepted media target is WebRTC P2P-first, with a dedicated LiveKit SFU/UDP virtual parent feeding only the necessary roots when direct/STUN peer paths cannot serve them. Roots continue distributing to bounded peer descendants; this is not an always-SFU conferencing design. Viewers should be able to join from a desktop or mobile browser without installing the sender application. +Screener is a private, low-latency screen-sharing tool for one game player and +up to 20 authenticated friends. Web Viewers need no installation. Media is +automatic and P2P-first, with bounded Browser relay and one dedicated LiveKit +SFU/UDP fallback rather than an always-SFU conference topology. -The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a random four-digit room code plus an independent fragment invitation. During a share, the host can change between three ceiling profiles without reopening the source picker, pause, and switch sources. Browser video is fixed VP8 and leaves the video content hint unset. Code-only entry defaults to `open` after site access; the only other state is `private`, which is invitation-only without a room password and also permits matching code-and-password entry when one is configured. A valid grant enters directly in either state. Rooms are process-memory state with a configurable dormant lease and disappear on restart. Lightweight deployments may keep independent direct WebRTC connections. The flagship deployment enables the all-room controller with bounded peer descendants and one LiveKit Host publication serving admitted Viewer subscriptions. Production admits 20 viewers; that admission limit is not a performance promise. +[Project memory](./docs/project-memory.md) is the compact product map. +[Current status](./docs/status.md) owns the exact source and production state; +this README intentionally does not mirror release details. -This is not yet a broadly validated product release; [Current status](./docs/status.md) owns the deployed revision and validation evidence. The deployed flagship boundary is the all-room bounded peer/SFU-UDP controller plus the memory-resident room/access model on `screener-v12`, with ordinary peer ICE kept STUN-only and no TURN or TCP media route. The optional Windows native sender remains source-only and is not a production runtime. Mobile devices are Web viewers only; ordinary Android and iOS browsers do not expose reliable screen capture for a Web Host. Real cross-network SFU media, game audio, a sustained public-network 1:20 session, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. - -## Run locally +## Local Development Node.js 24 and npm 11 are required. @@ -15,38 +18,29 @@ npm ci npm run dev ``` -Production requires an independent 8-128 byte visible-ASCII `SITE_ACCESS_PASSWORD`. It protects room creation, Host role, and code-only Viewer entry. A valid room-scoped fragment grant enters directly without it; otherwise code-only entry needs site access before the room's `open | private` policy is evaluated. Local development and tests may leave it empty. +Open `http://localhost:8787`. The development server listens on `0.0.0.0`, so a +phone on the same LAN may use `http://:8787`. Screen capture +must remain on `localhost` or HTTPS. Set `PUBLIC_BASE_URL` and +`ALLOWED_ORIGINS` when generated invitations need a different LAN origin. -Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a -phone on the same LAN can load `http://:8787`. To generate an -invite that the phone can open while the host keeps using secure-context -`localhost` capture, set `PUBLIC_BASE_URL` to that LAN URL and include both -origins in `ALLOWED_ORIGINS`. LAN viewers can join over HTTP; the sharing page -must remain on `localhost` or HTTPS because screen capture requires a secure -context. The local default has no STUN, so cross-network use still requires -HTTPS and the production ICE configuration documented separately. +Production requires an independent visible-ASCII `SITE_ACCESS_PASSWORD`; local +development and tests may leave it empty. Cross-network setup is documented in +[self-hosting](./docs/operations/self-hosting.md) and +[configuration reference](./docs/reference/configuration.md). Never commit real +credentials. -`SITE_ACCESS_PASSWORD` supplies site access; valid room fragment grants bypass -that gate only for the exact Viewer role, while code-only entry requires site -access before code policy is evaluated. Ordinary ICE contains only self-hosted -STUN. LiveKit supplies bounded SFU/UDP subscriptions when direct/peer paths -cannot serve an edge; Screener configures no TURN or TCP media route. HTTPS/WSS -remains TLS/TCP independently. - -Run the complete automated validation with: +Run complete automated validation with: ```sh npm run check ``` -Start here: +## Documentation -- [Requirements](./docs/需求理解.md) -- [WebRTC PoC design](./docs/方案设计.md) -- [Deployment and media transport](./docs/deployment.md) -- [Feasibility research](./docs/research/webrtc-p2p-screen-sharing.md) -- [Current project memory](./docs/project-memory.md) -- [Current status](./docs/status.md) -- [Architecture decision](./docs/adr/0001-p2p-first-media-topology.md) -- [Contributing and Git workflow](./CONTRIBUTING.md) -- [Documentation index](./docs/README.md) +- [Product and repository map](./docs/README.md) +- [Rooms and access](./docs/product/rooms-access.md) +- [Routing and transport](./docs/product/routing-transport.md) +- [Capture and media quality](./docs/product/media-quality.md) +- [Presentation and lifecycle](./docs/product/presentation-lifecycle.md) +- [Current work](./docs/todo.md) +- [Contributing workflow](./CONTRIBUTING.md) diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example index 5a4c551..425ab61 100644 --- a/deploy/systemd/screener.service.example +++ b/deploy/systemd/screener.service.example @@ -15,6 +15,8 @@ Restart=on-failure RestartSec=3s TimeoutStopSec=20s UMask=0077 +StateDirectory=screener +StateDirectoryMode=0700 NoNewPrivileges=true PrivateDevices=true diff --git a/docs/README.md b/docs/README.md index 2a83736..a69c231 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,34 +1,72 @@ -# Project Documentation +# Documentation Map -- [需求理解](./需求理解.md): current product scope, priorities, constraints, and acceptance criteria. -- [方案设计](./方案设计.md): accepted implementation design; unfinished release and validation work is tracked in the current TODO ledger. -- [Project memory](./project-memory.md): durable current product and source snapshot to read at the start of future work. -- [Current status](./status.md): bounded current/index snapshot of production, active milestones, decisions, blockers, and links to detail. -- [Verification status](./verification-status.md): exact integrated/deployed gates, current production evidence, open physical proof boundaries, and expensive-test applicability. -- [Session handoff](./session-handoff.md): temporary bootstrap prompt that points a new session back to the canonical truth owners. -- [Current TODO](./todo.md): unresolved work, active truth holds, and decisions that still require user confirmation. -- [Deployment](./deployment.md): exact current production, immutable release operations, scoped recovery rules, and transport evidence boundaries. -- [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation. -- [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment. -- [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, deterministic sticky topology, exact-route implementation evidence, and remaining physical gates. -- [Low-server-cost media routes](./research/low-server-media-routes.md): Host-publication/SFU distribution, route diagnostics, direct-to-SFU conclusion, hop-level accounting, privacy-safe ICE evidence, and bounded transport gates. -- [Built-in peer ICE TURN candidate](./research/built-in-peer-ice-turn.md): historical evidence for the rejected participant-wide and selected-edge TURN candidates. -- [Advanced peer distribution](./research/advanced-peer-distribution.md): generic local-reconcile evidence plus multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, and MoQ gates. -- [Native shared-encode sender](./research/native-shared-encode-sender.md): consolidated Draft #16/#18/#22/#23/#25/#28 ladder, unclassified product-gate failure, staged revalidation, and stop line. -- [Native H.264 hardware decision](./research/native-h264-hardware-decision.md): bounded WebCodecs no-go, proved Media Foundation/NVIDIA hardware encode, the default-fmtp `42c01f` stop, and the retained interop boundary. -- [Native H.264 opt-in path](./research/native-h264-opt-in-path.md): explicit H.264 sender mode, exact Pion profile registration, and one bounded Host/Viewer loopback with hardware caveats. -- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): Browser codec and `motion` evidence, manual quality ceilings, path-quality authority, and LiveKit-owned SFU representation adaptation. -- [Cross-restart room recovery](./research/cross-restart-room-recovery.md): room-authority lineage, optional SQLite stable mode, invitation continuity, and process-fresh media rebuild evidence. -- [Browser background capture diagnostics](./research/browser-background-capture.md): browser lifecycle evidence, a privacy-bounded capture/send/receive experiment, interpretation gates, and non-goal keepalive mechanisms. -- [Browser screen-audio quality](./research/browser-screen-audio-quality.md): capture/source compatibility, live sender-ceiling readback, Opus negotiation boundaries, and remaining audible/device evidence. -- [Display name and presence boundaries](./research/display-name-presence.md): browser-local storage failure behavior, Unicode normalization, control-character policy, and the Web-only presence capability boundary. -- [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository. -- [ADR-0001](./adr/0001-p2p-first-media-topology.md): historical P2P-first baseline; later access and automatic-routing decisions supersede its stale current-state details. -- [ADR-0002](./adr/0002-memory-resident-protected-rooms.md): four-digit leased room authority with lightweight memory and optional SQLite stable modes, local Host defaults, scoped Viewer grants, and explicit room replacement. -- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): historical standard-WebRTC peer-assisted experiment and reusable evidence. -- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): accepted P2P-first single-reconcile route model, including deployed Browser SFU ICE-server isolation; remaining real-network validation is tracked in verification status. -- [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): proposed fixed-`HIGH` native canary, current no-go result, and staged revalidation boundary. -- [ADR-0007](./adr/0007-path-isolated-representation-quality.md): accepted LiveKit-owned SFU representation adaptation, pinned evidence, and application-controller/SVC boundaries. -- [ADR-0008](./adr/0008-window-scoped-audio-capture.md): browser window-audio hint and the Windows WASAPI process-loopback boundary. +Start with [project memory](./project-memory.md), [status](./status.md), and +[TODO](./todo.md). Read only the product, decision, evidence, or operations file +needed for the current task. -Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture. +## Current Product + +- [Rooms and access](./product/rooms-access.md) +- [Routing and transport](./product/routing-transport.md) +- [Capture, audio, and media quality](./product/media-quality.md) +- [Presentation and lifecycle](./product/presentation-lifecycle.md) + +These four modules own current product behavior. Source code and tests own +ordinary implementation and UI detail. + +## Current Indexes And Operations + +- [Project memory](./project-memory.md): compact cross-domain map and invariants. +- [Status](./status.md): exact current source, production, milestone, and holds. +- [TODO](./todo.md): the only current work ledger. +- [Verification](./verification-status.md): open physical evidence and expensive + gate applicability. +- [Deployment](./deployment.md): immutable application release and recovery. +- [Self-hosting](./operations/self-hosting.md): initial services, topology, and + operational verification. +- [Configuration](./reference/configuration.md): environment, secrets, bounds, + and ports. +- [Maintenance](./maintenance.md): truth ownership, context, and repository + lifecycle. + +## Decisions + +- [ADR-0001](./adr/0001-p2p-first-media-topology.md): initial P2P-first + baseline. +- [ADR-0002](./adr/0002-memory-resident-protected-rooms.md): room authority and + optional SQLite stability. +- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): Browser relay + experiment. +- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): current automatic + route and SFU resource model. +- [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): native sender + canary stop line. +- [ADR-0007](./adr/0007-path-isolated-representation-quality.md): framework- + owned media adaptation. +- [ADR-0008](./adr/0008-window-scoped-audio-capture.md): Browser/native window + audio boundary. + +## Evidence + +Routing and transport: + +- [WebRTC feasibility](./research/webrtc-p2p-screen-sharing.md) +- [Browser peer relay](./research/peer-assisted-media.md) +- [Low-server-cost routes](./research/low-server-media-routes.md) +- [Advanced distribution alternatives](./research/advanced-peer-distribution.md) + +Media and platform: + +- [Realtime quality and codecs](./research/realtime-quality-adaptation.md) +- [Browser background capture](./research/browser-background-capture.md) +- [Browser screen audio](./research/browser-screen-audio-quality.md) +- [Native sender and shared encode](./research/native-sender.md) + +Rooms and repository practice: + +- [Cross-restart room recovery](./research/cross-restart-room-recovery.md) +- [Agent context governance](./research/agent-context-governance.md) + +Research records verified facts, measurements, assumptions, licenses, and +remaining evidence boundaries. It does not own current product behavior or work +priority. diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 5a7cfbb..b7ec3b7 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -1,535 +1,221 @@ # ADR-0005: Automatic Hybrid Media Routing -- Status: Base routing and the evidence-only quality shadow are deployed on - strict v13. Exact current identity is owned by - [status](../status.md). +- Status: accepted; base route and evidence-only quality shadow deployed - Date: 2026-08-20 - Last updated: 2026-08-27 ## Context -Screener serves one broadcaster and a small group of trusted viewers. Direct -browser WebRTC gives the desired latency and avoids central media cost, but a -single Host cannot safely fan out to every Viewer and some networks cannot -establish a usable peer path. +Direct Browser WebRTC gives Screener its desired latency and distributed cost, +but one Host cannot fan out to every Viewer and some endpoint pairs cannot +establish usable direct media. The product needs automatic peer distribution and +bounded server fallback without exposing topology choices to users or turning +every room into an SFU conference. -The product therefore needs one automatic route controller. Users do not choose -or understand the topology. The controller may use peers and bounded -server-assisted resources, but it must keep the ordinary media graph -distributed, deterministic, authorized, and recoverable. - -This ADR records the accepted invariants and the complete assisted-route model. +This decision owns the route controller and resource model. The observable +product contract is [routing and transport](../product/routing-transport.md); +physical evidence is in the routing research documents. ## Decision -### Endpoint capacity +### One Capacity Rule -Every non-server endpoint uses the same server-authoritative downstream media -capacity: +Every non-server endpoint uses one server-authoritative steady outbound media- +copy cap `C`: default `2`, configurable only as `1`, `2`, or `3`. -- the deployment default is `2`; -- the only accepted static values are `1`, `2`, and `3`; -- an upstream receive edge does not consume this downstream budget; -- role, browser, user agent, device class, and page visibility do not create a - different release tier; -- a connected endpoint's server-authoritative effective downstream capacity is - the deployment value clamped by its current `0..C` availability advertisement; - `0` means it currently accepts no downstream child and does not create another - release tier; and -- one named configuration value and one shared implementation boundary own the - policy. Route code must not repeat literal policy numbers. +- one ordinary peer child consumes one copy; +- the Host's single SFU publication consumes one copy; +- upstream receive consumes none; +- role, Browser, UA, device, visibility, codec, and room Viewer limit do not + create another tier; +- a client may advertise only `0..C` currently available copies, producing the + controller's effective capacity. -The server returns the final deployment value as -`authenticated.endpointMediaCopyCapacity` on every successful signaling -authentication. Browser Host and Viewer clients validate `1..3` and use the -same value for authoritative child assignments and physical sender slots. Room -`maxViewers` is a separate participant-admission limit and never supplies a -sender budget. A missing or invalid capacity, old wire, or executable sender -fails before route authority is accepted. +One transition-overlap reservation may temporarily raise a producer to +`min(C + 1, 3)`. Commit or abort releases it. A fourth endpoint copy is never +authorized. SFU ingress and subscription egress are independent server resources +and are not inferred from endpoint child counts. -Without the peer-assisted controller, signaling still admits at most that many -active Host children per room. Excess admitted Viewers wait without an -authorized media edge and are promoted in stable join order when a slot is -released; offer, answer, candidate, and restart routing is limited to the active -set. +### One Graph And Reconcile Loop -Capacity counts active outbound media copies produced by a non-server endpoint: -an ordinary peer child consumes one slot and the Host's single SFU publication -consumes one Host slot. An upstream receive edge is free. A parallel -media-producing candidate requires a separately reserved transition slot. SFU -subscriptions consume server egress, not endpoint capacity. +One room controller owns: -### Active topology +- one committed, versioned, acyclic, source-reachable graph; +- one event-driven reconcile loop; +- at most one room-serial child operation; and +- participant sessions, effective capacity, current failure facts, and the + Host's optional SFU publication. -The server owns one versioned route assignment per room. At every committed -revision: +The Host has no upstream. Each Viewer has zero or one active upstream. Healthy +unaffected edges remain sticky. Join/waiting, failed-edge repair, relay-ingress +repair with subtree retention, confirmed departure, and capacity reduction are +inputs to the same loop rather than separate routing systems. -- the Host has no upstream; -- each Viewer has exactly zero or one active upstream; -- the active media graph is acyclic; -- every non-server endpoint stays within its steady sender capacity or one - explicitly reserved transition-overlap slot; -- every active Viewer is source-reachable through peer edges or the current - authoritative Host publication; -- healthy unaffected branches remain sticky; and -- admission or recovery ends in a valid assignment, an explicit wait, or a - bounded failure. +Disconnected childless participants are removed naturally. A disconnected +relay or a relay with reduced capacity accepts no new children; only its +deterministic overflow children are reassigned. A relay whose ingress fails is +itself reparented while its descendants remain attached. -Direct or peer UDP remains the first media choice. Ordinary peer -`RTCPeerConnection` instances receive STUN candidates only. The sole -application fallback is the dedicated LiveKit SFU over UDP; Screener configures -no TURN, ICE/TCP, media TCP, or TLS-relayed media. HTTPS and WSS continue to use -TLS/TCP independently of media transport. +### Deterministic Candidates -Routing is event-driven. Join, capacity release or reduction, endpoint departure, -current-edge hard failure, and a non-paused decoded-frame stall wake the same -reconciliation loop. The controller otherwise leaves the room unchanged. +Each child operation owns one candidate list and cursor, one current candidate +and reservations, one route-demand owner, one fact version, and one total +deadline. Candidate creation first rejects stale sessions, unreachable sources, +cycles, insufficient steady/overlap capacity, tuples already consumed by that +operation, and unavailable SFU resources. -Parent selection is deterministic and local. The controller first filters on -current authority, source reachability, acyclicity, effective downstream -capacity, sender reservations, candidate tuples already tried by the current -operation, and server admission. -It then -orders eligible parents lexicographically by the shallowest resulting depth, -the greatest remaining steady sender capacity, a stable child-parent hash, -join order, and peer identity. The pairwise hash distributes otherwise equal -first choices without weakening capacity authority. It prepares one candidate at a time. The candidate's standard ICE -checklist proves transport connectivity; the exact candidate child's first new -decoded video frame is the application media-ready event. Raw addresses, a -claimed NAT class, geography, user agent, or a -weighted room-wide score never choose a parent. A failed candidate releases its -reservation before the next candidate is attempted. The first candidate that -reaches the media-usable floor commits; otherwise the loop reaches the next -candidate, an explicit wait, or bounded failure. A failed edge seeds its tuple -only into the operation opened by that fact version; a later external fact may -make the tuple eligible again. Exhaustion retires an edge that is hard-invalid, -attached to a confirmed-departed parent, or outside current effective capacity, -so blocked state never hides a physical copy or server resource. A healthy edge -used only for SFU bootstrap remains committed if bootstrap cannot start. +Eligible Peer parents are ordered by: -There is no independent maximum-depth policy. Acyclicity and room admission -bound the graph, while shallowest-first ordering minimizes depth. If a departed -Host root releases a Host slot, a new or orphaned child therefore prefers that -shallower Host result over another root's deeper slot. Healthy committed edges -remain sticky; the controller does not periodically rebalance the graph. Depth -remains an observed acceptance metric. +1. shallowest resulting depth; +2. greatest remaining steady capacity; +3. a stable child-parent rank; +4. join order; and +5. peer identity. -A candidate identity is one logical upstream path. An operation opened by a -failed edge seeds that exact current candidate as already tried, so the same -parent cannot immediately repeat. A new external fact may make the old -candidate eligible again; failure does not globally exclude that parent. +There is no independent depth cap. Room admission and acyclicity bound the graph; +shallowest-first is a preference, not a periodic balancing mandate. IP address, +geography, NAT guess, UA, and scalar quality score do not select a parent. -Foreground availability and direct convergence have separate priorities but -use the same controller and candidate transaction. Route acquisition gives the -best eligible direct parent one five-second head start before SFU. An exact hard -failure may advance another direct candidate inside that same window, but the -window never resets. If that exact direct candidate reports standard WebRTC -transport `connected`, the controller keeps it until the operation deadline; -this is progress only and cannot commit a route. Reaching the foreground -boundary without that progress defers the unresolved direct candidate rather -than declaring it failed, then starts SFU. A Viewer that -committed SFU retains that healthy route while the controller later tries its -remaining direct parents, one exact candidate at a time and round-robin across -SFU Viewers after each consumed candidate. Newly unrouted or repairing Viewers -always preempt this background direct work. The first direct -candidate that proves a decoded frame replaces SFU make-before-break; exhausting -the finite parent list simply keeps SFU. A candidate that would require a -bounded-gap cutover is skipped because background convergence may not interrupt -current media. This is event-driven route convergence, not periodic rebalancing -or quality-based switching. +A failed exact tuple is consumed only for its current operation. A later external +fact may make it eligible again; there is no persistent parent blacklist. -If an SFU subscription is needed while the Host has no publication and all Host -slots are occupied, the controller retains one bounded bootstrap intent: the -original demand and session, plus the finite Host direct children already tried -as carriers. Each carrier uses the ordinary child -operation and must fit in the existing overlap allowance; a transition that -would first cut a healthy carrier is ineligible. Carrier failure leaves that -carrier's committed route intact and advances the intent. Exhausting all safe -carriers fails the waiting demands for that fact, never the healthy carriers. -On success, the original demand tries the new publication before its eligible -direct candidates; after its first decoded SFU frame those direct candidates -resume as background convergence. A new external fact may rebuild the finite carrier -list. This intent is part of the same controller and owns no second graph, -concurrent route operation, or independent timer. +### Prepare, Commit, And Rollback -### Authorization and transition +Every operation is fenced by room and endpoint sessions, share generation, base +and pending route revisions, and one candidate connection identity. SFU routes +also carry a publication generation. Duplicate current messages are idempotent; +stale asynchronous results fail closed. -Every prepare, signal, recovery, commit, and rollback is bound to the -authenticated room and endpoint sessions, current share generation, base route -revision, and unique pending revision. Connection identity fences the candidate -PeerConnection, and publication generation remains owned by the SFU resource -lifecycle. -A single pending child-operation object aggregates these bindings, its exact -media child and route-demand owner, the deterministic candidate list and cursor, -its current candidate and reservations, and one deadline. Candidate failure -advances only that exact cursor entry. -Initial availability keeps one bounded direct window before SFU. Background -direct convergence uses the operation's full deadline for each remaining exact parent; -its finite tried set survives between attempts, rotates to the next SFU Viewer -after a consumed candidate, and a higher-priority route demand aborts the -current attempt without marking the parent failed. These -fields do not create another graph, concurrent pending route revision, periodic -timer, assignment generation, or all-pairs probe. -A `prepare` route update names that operation's exact child, route kind, and -server-issued candidate connection identity. Parent and child therefore -prepare the same connection; neither endpoint infers candidate authority from -an assignment-list difference. The candidate child may report one exact -transport-connected progress event fenced by the same revision and connection; -only its later decoded-frame ready event can commit. -The accepted Browser runtime contract uses the single `screener-v13` wire. On each WebSocket, the -server sends the exact prepare before its SFU configuration; the candidate child -is queued before a peer parent is allowed to start its offer. WebSocket ordering -is the companion-delivery contract, so clients keep no reordering inbox. -Duplicate current companions are idempotent and stale ones are ignored. Every -other wire and executable sender fails the protocol boundary before room -authority rather than receiving a compatibility path. -A stale or mismatched asynchronous result fails closed and cannot revive an old -edge. -Successful candidate `P` is broadcast as active revision `P`. Failure, timeout, -or authoritative abort keeps the previous committed graph content but advances -and broadcasts one active rollback revision `R > P` before any later prepare; -clients never infer rollback from silence or from an older revision. -One room-wide monotonic allocator owns active, prepare, rollback, retirement, -and prune revisions. A direct peer transport may adopt a fresh WebRTC connection -identity during its framework-owned rebuild only for the exact current -parent/child sessions; that identity update does not change topology. +The old committed route remains authoritative while the candidate prepares. +Parent and child prepare the same server-issued connection identity. Standard +ICE connected is progress only. The exact candidate child decoding its first new +video frame is the sole application commit proof. -A route replacement uses make-before-break only when the typed endpoint and -server-resource ledger atomically admits the required reservations. The old -route remains authoritative until the exact candidate child decodes its first -new video frame. ICE `connected` alone is insufficient. Commit promotes that -exact candidate -atomically; timeout, failure, stale identity, or revoked authority destroys it, -releases reservations idempotently, and keeps or restores the previous valid -route. The exact media-ready call synchronously commits typed admission before -graph promotion; a failed admission commit follows the ordinary candidate -failure path. Commit releases the resource-set difference plus the overlap -slot, and room stop/delete returns every current and committed resource through -one dispose operation. When a full sender has no overlap slot, server resources -are preflighted before one explicit bounded-gap cutover. Retirement keeps the -same total deadline, replans the untried tuple suffix against the new graph, and -appends one ordinary restore candidate when it cut a healthy logical edge. -There is no fourth endpoint copy and no separate restore state machine. +The media-ready call commits typed endpoint/SFU admission before graph promotion. +Success broadcasts the candidate revision. Failure, timeout, pause, or stale +authority destroys the candidate, releases reservations idempotently, and +broadcasts a strictly newer rollback revision before another prepare. Clients +never infer rollback from silence or an older revision. -Recovery attempts are finite, generation-bound, and stop after success. The -controller must not turn a rare failure into an unbounded retry loop, unaccounted -server fanout, or an untracked parallel route. +Make-before-break is used only when all required reservations exist. Availability +repair may use one explicitly planned bounded-gap retirement after server +resources are preflighted; it keeps the same deadline, replans the untried suffix, +and retains one restore tuple. Background convergence never interrupts healthy +media for a bounded-gap candidate. -## Accepted Assisted-Route Model +### Availability Order -One room controller owns the committed graph, one pending child operation, and one -event-driven reconciliation loop. Allocate/distribute, child reparent, and relay -abdicate/drain are inputs to that loop rather than separate state machines. -Topology helpers retain participant metadata and choose candidates without a -second mutable graph. +Initial acquisition gives direct Peer candidates one bounded foreground window. +Hard failure may advance a different direct candidate inside that same window, +but the window and total operation deadline never reset. Exact transport- +connected progress may retain the current direct candidate until the deadline; +it still cannot commit without decoded media. -- The loop first removes disconnected childless participants, then selects one - connected waiting Viewer or one child whose parent is disconnected, - lacks effective capacity for that child, or owns the exact failed edge. It - prepares one upstream change and wakes again after commit, abort, or another - room event. -- A relay whose ingress fails is itself the child being reparented; its subtree - remains attached. A disconnected endpoint, or one with effective downstream - capacity `0`, accepts no new children. Direct or deterministic overflow children - are reparented one at a time by the same loop, and disconnected leaves are - removed naturally. -- The Host is the only source publisher. There is at most one authoritative Host - publication per share generation, and every SFU-fed Viewer subscribes to it. - The Host page owns the canonical capture tracks. LiveKit publisher teardown, - remote room deletion, and transport recovery may release only their SFU - publication and must never stop those capture tracks. - Any source-reachable endpoint with effective capacity may be a parent - candidate; SFU-fed and peer-fed endpoints use the same provisional-child - transaction and each child edge commits independently. Viewer republishing - into a second SFU publication is outside the current product. -- The only server-assisted route is an SFU subscription backed by the Host's - one direct-ingress publication. Screener configures no TURN or media TCP - transport. An all-UDP-blocked network reaches a clear bounded failure; any - future strict-firewall transport requires its own evidence and belongs inside - LiveKit rather than becoming another application candidate. -- Browser SFU publisher and subscriber PCs explicitly use an empty ICE-server - list, retain LiveKit-signaled UDP candidates, and let standard ICE nominate a - non-relay pair. Candidate type, address family, count, and Browser socket - allocation are not application invariants. Ordinary peers still use - deployment STUN, and LiveKit server-side public-IP discovery may use that same - STUN service. This isolates SFU PCs from unnecessary external STUN/TURN - destinations without adding a route, transport, port, retry stage, or timeout. -- Browser ICE connectivity checks are the authority for direct UDP reachability. - Screener does not synthesize remote candidates, predict ports, classify NAT - behavior, or use TCP reachability as a media-path probe. Direct exhaustion - advances to the SFU stage inside the same operation deadline. -- Endpoint sender capacity is accounted independently from server ingress/egress, SFU - subscriptions, and one bounded transition-overlap slot. - Steady capacity is `1`, `2`, or `3` (default `2`); a transition may use - `min(steadyCap + 1, 3)` only for one fenced, deadline-bound handoff and must - return to steady bounds at commit. There is no fixed SFU-root count or - room-wide selected-lease count. -- Candidate-list creation, reserve, prepare, the child's first decoded frame, - atomic commit, abort, and idempotent release are one bounded child operation. - Its one total deadline cleans abandoned reservations and never revokes a - healthy committed edge. While that one exact candidate is pending, its - receive-transport owner runs one short-lived, generation-fenced - `framesDecoded` observer and stops it on proof, promotion, pause, replacement, - or teardown. A fresh peer connection or fresh SFU activation proves a - positive cumulative count; a paused prepared SFU route first records a - Resume-time baseline and requires later progress. This observer adds no - success/failure deadline and cannot select, reject, or reorder candidates. -- A child invalidates only its own exact edge after PeerConnection hard failure - or a named non-paused interval without a newly decoded frame. Bitrate, FPS, - resolution, blur, and sender statistics remain diagnostics or stock - WebRTC/LiveKit adaptation inputs; they do not change - parent eligibility. Multiple bad child edges recover independently through the - same loop and naturally empty an unusable relay. -- A currently decoding exact edge remains sticky. Current-path loss, RTT, - jitter, bitrate, resolution, FPS, freeze, and limitation evidence cannot prove - the counterfactual quality of another parent, so Screener creates no weighted - route score, alternative-parent probe, hysteresis loop, or periodic - quality-driven reparenting. -- Quality selection begins with one evidence-only shadow phase inside this same - controller. A Viewer reports standard freeze and pause deltas only for a - window with continuing decoded-frame progress. Presentation eligibility - separately requires that its exact current route is connected, the page is - visible and not suspended, the Host is not paused, the native video is - playing, and a current composited frame has already been proved. Current target Chromium does not - expose the W3C `framesRendered` member, so it is not a wire requirement; the - freeze/pause counters themselves remain renderer-derived. A strictly monotonic - presentation epoch fences every presentation-eligibility change; its first - new stats sample establishes a baseline and is not evidence. A zero-decoded - window neither enters the aggregate nor advances that epoch or freshness; a - later positive-progress window may carry the renderer's newly settled - freeze/pause delta. The server then revalidates the authenticated - Viewer session, exact committed upstream and connection before the controller - keeps one bounded current-epoch aggregate per child. Missing stats remain - unknown, recovered freeze/pause duration may exceed one report window, and a - stale aggregate disappears from the on-demand Host diagnostic snapshot. - Shadow observation never changes facts, capacity, graph, revision, candidate - order, SFU admission or reconciliation. Active trials remain unaccepted until - annotated production evidence establishes a usable trigger and disruption - boundary; any accepted trial must reuse this controller's single child - operation, reservations, first-frame commit and rollback rather than create a - second optimizer. -- Web clients derive active decoded progress from their existing periodic - WebRTC/LiveKit stats sampling. One route-keyed last-progress deadline reports - the exact edge once; it resets on route/connection change or decoded progress - and is suppressed while authoritatively paused. An active SFU route keeps that - same cadence when its video track is absent, its merged report is empty, or a - stats read fails; each such sample means no decoded progress, not a fabricated - metric or an immediate failure. Only route deactivation or teardown stops the - cadence, and stale asynchronous samples are generation-fenced. This steady- - state sampler is separate from the pending candidate's short-lived first-frame - observer and does not treat bitrate, FPS, track availability, or SFU layer - choice as route authority. -- Browser page resume/visibility recovery rebaselines this same deadline before - another no-progress sample can invalidate an edge. Frozen JavaScript wall time - is not media-failure proof. A newly composited current-generation frame clears - stale local recovery presentation but does not create route authority. -- Manual media reconnect remains on the current exact route. P2P directly - rebuilds the connection to the existing parent; SFU - disconnects and reconnects only the current LiveKit subscriber with fresh - scoped configuration. Neither action restarts Screener signaling, changes the - route revision, performs quality selection, or reparents. Only actual recovery - exhaustion reports route failure and enters normal reconciliation. -- Authoritative pause aborts the pending child operation, including its current - candidate and reservations, keeps the active graph, suppresses decoded-frame- - stall decisions, and leaves new participants waiting. Resume always wakes a - fresh reconciliation from the committed graph. Healthy unaffected edges remain - sticky. +When the direct foreground window ends, a usable SFU candidate may provide media +before unresolved direct candidates. After an SFU route commits, finite remaining +Peer candidates converge behind working media, one at a time and round-robin +across SFU Viewers. New join or repair work preempts this background convergence. +Exhausting direct candidates simply keeps the working SFU route. -SFU remains a bounded fallback resource with independent deployment-wide -admission. Resource exhaustion produces an explicit wait or failure; it never -creates unbounded central fanout. -Rooms that actually lose a candidate to deployment-wide admission register in -one waiter set. An actual SFU usage decrease drains that set once, advances -each waiting controller's external fact, and schedules normal reconciliation; -there is no periodic capacity poll or resource-specific route controller. +If the Host is full and no publication exists, one bounded SFU-bootstrap intent +owns the original waiting demand and a finite cursor of eligible Host-direct +children. A carrier must create the publication through a normal overlap; it may +not cut a healthy branch first. Carrier failure keeps that branch and advances +the cursor. Success lets the original demand try the publication before its +remaining direct candidates. The intent owns no second graph, concurrent child +operation, or timer. -The accepted Viewer wire includes one strict, revision-fenced `route-status` -union only for states that the existing prepare/active `route-update` cannot -express: `{ state: "waiting", reason: "sfu-admission" }` while this Viewer is -waiting on central admission, or `{ state: "failed", reason: -"route-exhausted" }` after the controller reaches its bounded terminal. It is -sent only server-to-authenticated-Viewer and carries no text, identity, -candidate, retry hint, or other topology data. Access, Host presence, signaling, -media, and autoplay remain independent presentation inputs. +### SFU Resource Ownership -The room-serial operation does not divide its per-child deadline by Viewer -count, but simultaneous joins may form a linear queue. The current Browser -diagnostic owner observes existing route events without changing the controller: -for each current child it keeps only the latest route-demand, operation-start, -current-candidate-start, first-decoded-frame, and final-outcome timing. A new -demand overwrites that child's record; authoritative share stop/replacement, -confirmed departure, and room deletion remove it. +There is at most one Host publication per room/share generation. Every SFU +Viewer owns one subscription to it; Viewers never publish a second SFU stream. +The Host page owns capture tracks, so SFU teardown or recovery must not stop +capture. -An authenticated Host acceptance harness may request one snapshot. The current -product UI exposes no diagnostic download button. The response -reads the current graph, current operation, and latest records; the server does -not push a periodic snapshot stream, start a timer, retain an event ring, log or -persist the result, or create a second graph. Snapshot-local ordinals express -parent/SFU relations but are not participant identities and must not be treated -as stable across snapshots. Queue wait is the interval from the same route -demand to operation start and remains `null` when either event is absent. +The single-process server has one injected SFU admission owner. Its ingress +ceiling derives from the fixed room-code space, and egress derives from that +space times the per-room Viewer limit; neither has an independent tuning key. +Exact publication and subscription handles remain charged while reserved, +committed, or draining. Reactivating the same handle does not double-charge it. -The response uses only `direct | sfu | waiting | failed` final-route values and -the closed rejection buckets `none`, `stale`, `endpoint-capacity`, -`sfu-admission`, `candidate-failed`, `first-frame-timeout`, -`operation-deadline`, and `aborted`. It never carries real peer, parent, session, -connection or generation identity, SDP, candidates, addresses, credentials, or -raw error text, and it has no route authority. A 20-Viewer burst reports the -sample count and nearest-rank p50/p95/max without a pass threshold before any -different concurrency model or deadline is accepted. Protocol tests reject -extra/private fields, deny the request to Viewers, and prove departure and -room-deletion cleanup. +The application explicitly creates a managed LiveKit room before issuing media +tokens. Replacement, abort, timeout, confirmed media-participant loss, share +rollover, stop, and room deletion transfer the resources they own into the same typed drain path. +Only successful room deletion plus an absence readback releases that generation, +so a stale token cannot recreate off-ledger media. The deployment must dedicate +the LiveKit namespace to Screener and complete startup ownership/cleanup before +accepting traffic; [self-hosting operations](../operations/self-hosting.md) owns +that procedure. -For the single-process deployment, SFU admission is one injected authority with -deployment-wide ingress and egress counters. The fixed 9,000-room code space is -also the ingress ceiling because each room owns at most one Host publication; -egress is the same room capacity multiplied by per-room Viewer admission. -Neither has an independent setting. One exact room/share/publication entry owns -the Host publication ingress, while an exact Viewer subscription handle under -that entry owns one egress unit. The current publication is reused as -Viewers enter or leave the SFU path; each child candidate reserves and commits -only its own subscription handle. Reserved, committed, and draining handles, -concurrent publication generations, and generations still draining from -LiveKit all remain charged. A subscription that leaves the route remains -charged as draining until its publication room is deleted and proven absent; -reactivating that same Viewer handle does not charge it twice. +Only confirmed LiveKit media-participant absence retires an abandoned Host +publication. A Host signaling disconnect keeps the generation charged while the +exact LiveKit Host participant remains; one bounded control-plane check releases +it only after that participant disappears. -The accepted self-hosted deployment contract dedicates one LiveKit instance to Screener, -sets `room.auto_create: false`, and gives the application an explicit private -`LIVEKIT_API_URL`. The controller reserves the exact publication and first -subscription, creates that managed LiveKit room through `RoomService`, and only -then issues tokens. Later SFU-fed children reserve an exact subscription under -the same generation without republishing the Host. Publication replacement -moves the old generation and all of its subscription handles to `draining`; -abort, timeout, participant loss, share rollover, room stop, and room deletion -apply the same typed lifecycle to the resources they own. -`RoomService.DeleteRoom` must complete and a follow-up lookup must prove the -room absent before that generation's ingress or egress units are released. -Because joining cannot recreate a deleted room, a stale self-hosted token -cannot produce an off-ledger participant. +A room denied by deployment-wide SFU admission enters one bounded waiter set. +Actual SFU usage decrease drains that set once, advances each surviving room's +external fact, and schedules ordinary reconciliation. There is no periodic +capacity poll or second resource-specific controller. -The single owner first binds the configured application listener exclusively; -a competing process that cannot bind makes no LiveKit control-plane call. While -bound but not initialized, HTTP returns `503` and no signaling upgrade handler -is installed. The owner then lists its dedicated LiveKit instance, rejects -foreign room names, deletes every stale Screener room, confirms the owned -namespace empty, and only then accepts application traffic. Graceful shutdown -drains the same namespace before forgetting counters. A Host signaling -disconnect keeps a committed generation charged while its exact LiveKit Host participant exists; -an interval-bounded control-plane check retires it after that participant -disappears. A multi-process application deployment requires a shared atomic -admission and lifecycle owner before it may claim these values are -deployment-wide. +Ordinary Peers use deployment STUN only. Browser SFU PeerConnections use an +empty external ICE-server list while retaining LiveKit-signaled UDP candidates. +Screener configures no TURN, ICE/TCP, media TCP, or TLS-relayed media. A future +strict-firewall transport must be accepted as a LiveKit-internal capability, not +as another application route candidate. -## Current Source And Deployment Boundary +### Recovery, Pause, And Quality -Canonical application/runtime source and production use the evidence-only -strict `screener-v13` quality shadow. Exact current source and deployment -identity is indexed by [status](../status.md). -Browser SFU PCs use empty external ICE-server lists. The controlled exact- -candidate rollback/SFU commit and active-SFU cadence gates are closed. The -operation owner remains only `route`; broader heterogeneous-network and SFU -lifecycle validation remains open in the -[verification ledger](../verification-status.md). +WebRTC and LiveKit first own ICE/consent and transient reconnect on the current +route. Only framework recovery exhaustion, hard failure, a non-paused decoded- +frame stall, confirmed departure, or capacity invalidation enters route +reconciliation. Manual reconnect remains on the exact current route. -## Acceptance Boundary +Authoritative Host pause aborts the pending operation and reservations, preserves +the committed graph, suppresses decoded-stall authority, and leaves new Viewers +waiting. Resume starts reconciliation from the current graph. Page-hidden wall +time is rebaselined before it can contribute to a stall decision. -The deployed core's completed source and deployment evidence is indexed in -`docs/verification-status.md`. Any revised controller must re-pass these source -and deployment gates: - -- property tests cover capacity `1`, `2`, and `3`, one active upstream, - acyclic and source-reachable assignments, stale generations, duplicate - signals, departure, rollback, and admission exhaustion; -- route changes preserve unaffected branches and never commit before the exact - candidate child's first decoded-frame ready; -- SFU lifecycle tests keep reserved, committed, and draining generations charged - until deletion plus absence proof, reject stale-token room recreation, prove a - competing listener owner makes no LiveKit call, fence startup against stale or - foreign rooms, reclaim an abandoned Host generation - without cutting a live Host participant, and keep two rooms under one global - capacity owner; -- candidate lists are deterministic under input permutation, preserve a healthy - current edge, prefer the shallowest least-loaded parent with capacity, and - advance one cursor after exact failure and idempotent cleanup without resetting - the total operation deadline; -- focused controller tests cover first-frame commit, candidate failure and stale - ready, relay-ingress reparent with its subtree intact, disconnected relay and - nested-disconnect convergence, effective capacity `0..C` and overflow drain, - SFU-fed first-child use and pause/resume; -- deployment preflight proves the LiveKit instance is dedicated, uses - `room.auto_create: false`, exposes `RoomService` only on its accepted private - control origin, and can drain its managed namespace before traffic or - rollback. - -The following external acceptance remains open without changing the deployed -core's status: - -- representative-network real-browser tests cover direct peer media, peer - relay, SFU media, bounded failure, and recovery beyond the completed - controlled direct-to-SFU transaction; -- measured endpoint upload and server ingress/egress prove the accepted - accounting under normal and migration overlap; and -- every exhausted path reaches a clear bounded wait or failure without leaking - credentials, candidates, addresses, or raw errors. - -Loopback timing and synthetic signaling tests may validate invariants, but they -do not prove target-network latency, quality, capacity, or interoperability. - -## Security and privacy - -Route and signaling authority is room-, role-, session-, share-, revision-, and -connection-bound. LiveKit media credentials are short-lived and bound to the -room, role, share, and publication generation so healthy media may survive a -signaling reconnect. Server-assisted credentials remain memory-only and never -appear in application page URLs, browser persistence, or durable server storage. LiveKit places -its short-lived JWT in the WebSocket transport request target, which must not be -logged; no credential may enter application or proxy logs. -Ordinary SFU transport terminates DTLS-SRTP at the SFU; the product must not -claim application E2EE unless key distribution and real media evidence exist. -Authenticated Hosts and shipped clients are trusted media participants in the -current private-room threat model. Stale route-control sessions remain -unauthorized; a retired media generation remains charged during bounded drain -until deletion of its exact room prevents re-entry. +Quality evidence is observation-only. Current low bitrate/FPS/resolution, loss, +RTT, jitter, freeze, or sender limitation does not change candidates, capacity, +SFU use, or graph authority. Screener has no weighted route score, parent-wide +quality inference, all-pairs probe, hysteresis controller, or periodic rebalance. +Open investigation remains in [TODO](../todo.md). ## Consequences Positive: -- one capacity rule replaces role and browser policy forks; -- route safety is expressed through graph, identity, and media invariants; -- server-assisted paths remain bounded and explicit without turning the room - into an always-SFU topology. +- one capacity rule replaces role- and Browser-specific tiers; +- one graph and operation make route, identity, and resource ownership explicit; +- media stays distributed while SFU cost and stale-token behavior remain bounded; +- unrelated healthy branches survive local join, failure, and repair. Negative: -- one room-serial operation may create measurable queue-tail latency during a - burst; -- make-before-break consumes explicit endpoint and server reservations and may - require a bounded-gap cutover when no overlap slot exists; and -- real SFU and target-network evidence is still required; diagnostics remain - observational and do not grant route authority or close physical evidence. +- the serial operation can create queue-tail latency during bursts; +- Browser relay creates per-child decode/re-encode cost; +- make-before-break requires temporary reservations and some availability repair + may require a bounded gap; and +- UDP-only media ends in explicit failure on fully blocked networks. -## Relationship to other ADRs +## Related Decisions And Evidence -- ADR-0001 owns the original browser P2P baseline. -- ADR-0004 is historical evidence about browser peer relay and re-encoding; it - does not set current capacity or server-assisted policy. -- ADR-0007 owns diagnostic path-quality evidence and representation behavior; - it does not create route eligibility. +- [ADR-0001](./0001-p2p-first-media-topology.md): initial P2P baseline. +- [ADR-0004](./0004-peer-assisted-media-experiment.md): Browser relay evidence. +- [ADR-0007](./0007-path-isolated-representation-quality.md): framework-owned + representation adaptation. +- [Low-server-cost routes](../research/low-server-media-routes.md) and + [peer-assisted media](../research/peer-assisted-media.md): measurements and + rejected alternatives. +- [Verification status](../verification-status.md): current open physical gates. -## References +## Primary References - [WebRTC](https://w3c.github.io/webrtc-pc/) - [WebRTC statistics](https://www.w3.org/TR/webrtc-stats/) -- [WebRTC transports, RFC 8835](https://www.rfc-editor.org/rfc/rfc8835.html) +- [ICE, RFC 8445](https://www.rfc-editor.org/rfc/rfc8445.html) - [RTP topologies, RFC 7667](https://www.rfc-editor.org/rfc/rfc7667.html) -- [PIM-SM Join/Prune behavior, RFC 7761](https://www.rfc-editor.org/rfc/rfc7761.html) -- [ICE connectivity checks and candidate checklists, RFC 8445](https://www.rfc-editor.org/rfc/rfc8445.html) -- [NICE degree-bounded application-layer multicast](https://conferences.sigcomm.org/sigcomm/2002/papers/appmulti.pdf) -- [Overcast adaptive single-source distribution trees](https://www.usenix.org/conference/osdi-2000/overcast-reliable-multicasting-overlay-network) -- [Kubernetes cordon and drain](https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands) -- [LiveKit selective subscription](https://docs.livekit.io/transport/media/subscribe/#selective-subscription) -- [LiveKit track subscription permissions](https://docs.livekit.io/transport/media/publish/#track-permissions) -- [Kubernetes controllers](https://kubernetes.io/docs/concepts/architecture/controller/) -- [Consistent updates for software-defined networks](https://reitblatt.com/downloads/consistent-updates-hotnets11.pdf) +- [NICE degree-bounded multicast](https://conferences.sigcomm.org/sigcomm/2002/papers/appmulti.pdf) +- [Overcast](https://www.usenix.org/conference/osdi-2000/overcast-reliable-multicasting-overlay-network) +- [LiveKit selective subscription](https://docs.livekit.io/transport/media/subscribe/) +- [Consistent network updates](https://reitblatt.com/downloads/consistent-updates-hotnets11.pdf) diff --git a/docs/adr/0006-fixed-high-native-sender-canary.md b/docs/adr/0006-fixed-high-native-sender-canary.md index 9f6cdc2..36a815a 100644 --- a/docs/adr/0006-fixed-high-native-sender-canary.md +++ b/docs/adr/0006-fixed-high-native-sender-canary.md @@ -1,205 +1,60 @@ # ADR-0006: Fixed-HIGH Native Sender Canary -- Status: Proposed - Product Gate No-Go +- Status: rejected product candidate; evidence retained for later revalidation - Date: 2026-08-19 +- Last updated: 2026-08-27 ## Context -The Draft #16/#18/#22/#23/#25/#28 ladder proved isolated WebCodecs/Pion -properties, not integration with Screener's current rooms, signaling, ordinary -browser viewers, or per-edge ICE transport. A native sender remains a later -optimization and must not be presented as available product behavior. +A native sender could capture and encode once while serving bounded independent +WebRTC legs, reducing Browser capture/encode cost. Early WebCodecs/Pion and +Windows hardware fixtures proved isolated pieces but not compatibility with the +current room, signaling, routing, audio, packaging, and Browser Viewer contract. -## Retained Boundary +## Decision -Any renewed product-wiring canary stays deliberately narrow: +Do not integrate or ship the fixed-HIGH native sender. The current product +surface remains Web Host/Viewer/relay, and stale experimental wires receive no +compatibility reader. -- one fixed VP8 `HIGH` representation at 1280x720, 30 fps, and a 3 Mbps ceiling; -- one WebCodecs encoder object feeding at most two independent Pion WebRTC legs; -- the current strict ordinary-P2P signaling wire and unmodified browser viewers; -- independent STUN-backed ICE, RTP/RTCP, congestion, and lifecycle state per leg; -- read-only feedback, with PLI/FIR limited to requesting a shared keyframe; and -- bounded local queues, fail-closed critical errors, and sanitized local-only - diagnostics. +The retained one-Viewer loopback proved that a fixed 1280x720@30 source could +authenticate, negotiate one independent Pion leg, and deliver decoded/rendered +Browser frames. Later Windows WGC/Media Foundation work proved one attributed +hardware H.264 path. Those are bounded functional facts, not evidence for two +Viewers, shared congestion behavior, audio/A-V sync, public networks, recovery, +endurance, packaging, licensing, or production. -This canary excludes room-wide minimum feedback, automatic quality control, -`LOW`, viewer C reporting, peer-assisted/SFU routing, audio, packaging, and any -claim of one physical or hardware encoder. A third viewer must never create a -third host edge. The missing audio path remains a product-acceptance gap. +## Reopen Boundary -## 2026-08-19 Gate Result +A later native milestone must start from the then-current canonical contract and +stop at the first failed stage: -The sole authorized run is `no-go-unclassified`; the retained evidence and -probe limitations are recorded in the native sender research. The earliest -missing checkpoint is viewer authentication, not a proven authentication -failure. No viewer-1 acceptance means there is no two-viewer, third-viewer -FIFO, or direct/SFU proof. The attempted native branch must not be opened or -merged as product code from this state. +1. Prove current room creation, Host authorization, source/encoder identity, + bounded queues, source RTP, and zero critical errors. +2. Prove one ordinary Browser Viewer through the current signaling and routing + model, including exact offer/answer/ICE and decoded/rendered media. +3. Prove two independent transport legs while capture and encode remain shared + and endpoint capacity remains authoritative. +4. Prove a third Viewer waits without creating a third edge, then is promoted + when capacity releases. +5. Add application audio, PLI/FIR recovery, reconnect, source lifecycle, + public-network/endurance, packaging, signature/update, and license gates. -## 2026-08-20 Historical Wire Checkpoint - -This checkpoint targeted the then-current `screener-v2` wire and SQLite schema -v2. It is experiment provenance, not a current integration contract; current -source and production boundaries are owned by [status](../status.md), and -executable senders remain outside the current release. - -A clean local delivery candidate now targets only `screener-v2`: it exchanges -`SITE_ACCESS_PASSWORD` for the bounded HttpOnly site-access cookie, uses -that cookie to create an explicit `private-link` room, carries it on the Host -WebSocket upgrade, and strictly decodes the current ordinary authenticated and -read-only Viewer-evidence shapes. There is no v1 parser, translator, raw-grant -persistence, or Bearer room-creation shortcut. Its fixed 300-second provisional -request always creates a random transient room in memory, even when SQLite is -configured. A connected current Host suppresses that reclaim deadline; only its -generation-matched disconnect resets the five-minute window, while the ordinary -transient `ROOM_TTL_SECONDS` cap always remains. A pre-auth connection or -authentication failure gets one retry with the same room/token/client generation -and never another room POST. Server restart drops the room; SQLite stays at v2 -and ordinary Web rooms are unchanged. No claim-confirm message, durable native token, or recovery -subsystem is added. Focused Go/TypeScript tests and static checks pass. The unused -generic probe framework is deliberately not retained. A current command consumer -may keep only the minimum stage ledger needed by the next authorized run. - -The first authorized current-wire run on 2026-08-20 remains -`no-go-unclassified`. It used Windows amd64, Chrome 151, one real animated Chrome -tab, and an isolated local Node server with an in-memory RoomStore and peer -assistance disabled. Safe Chrome history booleans prove that the source and -Sender pages loaded and that no Viewer page was created. The runner stopped -after a bounded 20-second Sender-start interval without observing the combined -Sender-ready and Go source-RTP condition. - -The timeout path failed to preserve its final Sender DOM/counter sample. It -therefore cannot distinguish capture selection, site access/create, WSS -authentication, local bridge, first encoded output, or Go ingest as the first -runtime break. None is a proven product failure. No retry, timeout adjustment, -Viewer, second Viewer, or FIFO run followed. A separately authorized rerun must -first make the stage-1 ledger unconditional and prove that timeout retains the -last sanitized checkpoint; only a passed Sender/source-RTP stage may create the -single Viewer. The product gate remains no-go and this local branch is not a -product availability claim. - -The retained current command consumer appends and flushes only monotonic, -sanitized Sender-start fields. Its static one-Viewer gate now freezes one -socket/auth/connection/PeerConnection/video-track generation and the matching -Pion slot/edge generation, then revalidates that identity during media progress; -raw connection IDs never leave the page probe. Every CDP RPC and sample shares -its enclosing absolute deadline. `passed` is impossible until bounded Chrome -and native process-tree exit, listener closure, and audited deletion of the -exact task profile under system Temp all succeed; a reparse point or deletion -failure is a fixed cleanup failure. Pure checks cover retained timeout evidence, -hung samples, cross-generation rejection, cleanup-before-pass, and profile -cleanup failure. - -One separately authorized second run then used that frozen gate with the same -bounded local topology. Its retained ledger proves `getDisplayMedia` request and -resolution, site access and room creation, Host WSS authentication, local -bridge readiness and fixed-`HIGH` config acceptance, exactly one encoder object, -and entry into the first WebCodecs output callback. It serialized zero media -counters without retaining whether any diagnostics arrived, so those values do -not prove that Go reported zero. It stopped at `sender-start` and never created -a Viewer. No retry or threshold adjustment followed. Cleanup completed before -final failure: Chrome and Native exited, the -Node server and all three loopback ports closed, and the exact task Chrome -profile passed the non-reparse audit and was removed. - -This remains `no-go-unclassified`. The frozen probe counted the first encoded -chunk before invoking the product output callback and did not record whether the -generation-bound local bridge attempted, returned from, or threw during its -binary send. Static inspection finds matching 17-byte big-endian frame contracts -on the Web and Go sides. A focused integration now configures the real `/media` -handler, sends one same-contract binary frame, and observes positive Go frame and -source-RTP accounting. That proves the current envelope can enter the current Go -fanout in a controlled test, not that the retained browser executed its send. - -The handler no longer returns silently for every post-config bridge read error: -while the app context is active, any close other than normal or going-away emits -one `fatal` with the fixed sanitized message `local media bridge read failed`. -Focused tests prove the abnormal classification without its underlying close -reason and prove normal close and app shutdown do not misreport. This is a real -P1 bridge-read failure fix, but it does not classify the missing diagnostics -or prove a framing defect. - -One authorized evidence-clarification revalidation then ran the corrected gate -exactly once with the same fixed VP8 `HIGH`, Chrome 151, one in-memory local room, -and peer assistance disabled. It retained the same start sequence through one -generation-1 binary send return, then observed no diagnostics or post-send -diagnostics, zero encoder errors, and at least one sanitized fatal marker. Its -media-counter zeros are initial ledger values, not Go reports. The first failed stage remained -`sender-start`, no Viewer was created, and cleanup passed 5/5. No retry, -threshold change, H.264, second Viewer, FIFO, or production run followed. - -The result remains `no-go-unclassified`. It proves the first product binary -send returned, but the retained fatal marker and missing diagnostics do not prove -where or why the product path stopped. No raw cause may be inferred. There is -still no Viewer, two-viewer, FIFO, or physical or hardware-encoder proof. - -A source follow-up identifies and removes one concrete fatal path without relabeling -that retained run. WebCodecs output duration is nullable; the local sender uses -33,333 microseconds when it is absent, while a valid next capture timestamp may -arrive slightly earlier. The prior RTP timeline rejected that strictly -increasing pair as `encoded frame timestamps overlap`. The candidate now derives -RTP cadence from adjacent strictly increasing source timestamps and uses duration -only to report positive source gaps. Focused tests retain dropped-frame timing, -non-increasing rejection, and sub-sample progress; the real `/media` handler also -accepts two frames at 1,000,000 and 1,033,000 microseconds when the first reports -33,333 microseconds. This proves the source path no longer raises that fatal, not -that it caused the retained browser failure. - -## 2026-08-20 One-Viewer Loopback Revalidation - -A bounded Chrome 151 run with fixed VP8 1280x720@30 and peer assistance disabled -closed the base sender-to-Viewer path once. Sender/Go recorded 30 written frames, -85 source RTP packets, and zero fatal or encoder errors. The Viewer authenticated, -completed offer/answer/ICE, received 877 packets, and advanced from 0 to 299 -decoded and rendered frames at 1280x720. The probe was corrected to tolerate -pre-offer trickle candidates and a development-only pre-offer control-socket -replacement while retaining fail-closed checks after an active offer/PC. - -The run still ended negative at the gate's Pion outbound assertion because the -two-second Go diagnostics snapshot did not refresh during the media sample -(`pionPacketDelta=0`). This is a residual observation-timing gap; inbound packet, -decode, render, source-RTP, and no-fatal evidence are retained. The result is -therefore a one-Viewer functional loopback proof, not a broad Native acceptance: -two edges, FIFO, hardware, endurance, public-network, audio, and packaged-native -boundaries remain no-go. - -## Staged Revalidation - -Any further run requires separate authorization and must stop at the first -failed stage while synchronously retaining a bounded final-negative snapshot: - -1. Prove host room/auth, local config acknowledgement, one encoder object, - bridge ingress, frame decode, source RTP, and zero critical errors. -2. Prove viewer auth, `peer-joined`, exact offer/answer/candidate exchange, and - both PeerConnection state timelines. -3. Prove one viewer's inbound packets, `framesDecoded`, video readiness/current - time/dimensions, and rendered frames. -4. Add viewer 2 and prove two independent legs while encoder instances stay one - and host media edges stay at most two. -5. Add viewer 3, prove no third edge and a host-side waiting observation while - the viewer keeps its existing waiting state, then close viewer 1 and prove - FIFO promotion plus decoding/rendering. -6. Keep this candidate STUN-only. Direct failure remains inside ADR-0005's - dedicated Host-publication/SFU-subscription boundary; the Native canary does - not add another media transport or widen that route model. - -A commit-bound, short-lived evaluation artifact may package the current source -to run these stages without a development toolchain. That artifact is not an -acceptance or release claim. Only after these stages pass may separate -performance, quality, loss, reconnect, browser, audio/A-V-sync, formal release, -and license gates begin. Do not expand the feature to make the diagnostic gate -pass. +The canary may not add a parallel room model, old wire, custom congestion +controller, room-wide minimum quality, another media transport, or claims of +hardware acceleration without process-and-adapter evidence. Direct/SFU behavior +must remain owned by ADR-0005. ## Consequences -ADR-0006 records a bounded candidate and its failed first product gate, not an -accepted architecture or shipped sender. The deployed Web sender and ordinary -viewer remain unchanged. ADR-0007 continues to own path-isolated -representations; the current Browser SFU contract is one `HIGH`, and this canary -cannot advance or replace it. +- No native candidate constrains the current Browser product or route model. +- Existing native source and fixtures remain research/evaluation code, not a + production runtime. +- Revalidation cost is explicit and cannot be hidden by preserving deprecated + protocol or packaging surfaces. -## References +## Evidence -- `docs/research/native-shared-encode-sender.md` -- `docs/adr/0007-path-isolated-representation-quality.md` -- `docs/research/browser-screen-audio-quality.md` +- [Native sender and shared encode](../research/native-sender.md) +- [Browser screen audio](../research/browser-screen-audio-quality.md) +- [ADR-0005](./0005-automatic-hybrid-media-routing.md) diff --git a/docs/adr/0008-window-scoped-audio-capture.md b/docs/adr/0008-window-scoped-audio-capture.md index b1d3190..f46e844 100644 --- a/docs/adr/0008-window-scoped-audio-capture.md +++ b/docs/adr/0008-window-scoped-audio-capture.md @@ -1,154 +1,60 @@ -# ADR-0008: Window-Scoped Audio Capture +# ADR-0008: Browser Screen-Audio Scope -Date: 2026-08-21 - -Status: Accepted. The browser hint and opt-in Native Windows audio slice are -source-complete, including the bounded Windows x64 evaluation-package path. -The paired WGC/MF hardware-video source passed its one-Viewer acceptance smoke; -all Native sources remain default-off, formally unreleased, and undeployed. +- Status: accepted for Browser capture; Native process audio remains research +- Date: 2026-08-21 +- Last updated: 2026-08-27 ## Context -The Web Host currently receives an optional audio track from -`getDisplayMedia({ audio: true })`. A browser may return no audio, and the Web -API does not expose a portable readback identifying whether that track is a -tab, window, monitor, or whole-system mix. Capturing a system mix while a voice -application is open can leak that conversation back to Viewers. +`getDisplayMedia({ audio: true })` may return tab, window, monitor, or system +audio depending on Browser, operating system, selected surface, and user choice. +The Web API exposes no portable proof that a returned track is isolated to one +application. Sharing a system mix can unintentionally include calls or +notifications. -Windows has a stricter primitive: WASAPI application loopback through -`ActivateAudioInterfaceAsync` with -`AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS.INCLUDE_TARGET_PROCESS_TREE`. It can be -paired with a selected window's PID and a WGC/DXGI video capture. Microsoft's -process-loopback contract starts at build 20348; this product slice gates on -Windows 11 desktop (build 22000 or newer) and is not a Windows 10 fallback. +Windows offers a narrower native primitive through WASAPI application loopback +for one selected process tree, but that capability belongs to the unreleased +native sender candidate rather than the current Web product. ## Decision -1. The Web sender requests `windowAudio: "window"` for window surfaces and - `systemAudio: "include"` for monitor surfaces. The browser picker and user - consent decide whether audio is shared; older browsers may ignore either - hint, and track presence alone does not identify its source. -2. The default Web route remains unchanged in topology, codecs, and relay - behavior. Returned audio tracks use the standard `contentHint = "music"`; - this does not force a codec, bitrate, channel count, or processing mode. No - Web Audio mixer, SDP rewrite, or app-owned audio bitrate knob is introduced. -3. The native sender has an explicit Windows 11 local window target. Browser - VP8 may use that target only for `window-process-audio`; the separate - `native-window-h264` source uses the same bound HWND/PID/creation time for - WGC/DXGI video and WASAPI process-tree audio. Process IDs, HWNDs, creation - tokens, titles, paths, device identity, PCM, and local hardware status stay - local; they do not enter signaling, URLs, logs, or storage. -4. If the target process exits, produces no render stream, or the loopback - activation is denied, the sender reports `audio unavailable` or `audio - silent` and stops/asks the user. It never widens to whole-system audio. - Windows 10 remains explicitly unsupported until a separate API passes the - same isolation gate. - -## Minimal Native Boundary - -This is an interface boundary, not a new framework or a current wire change: - -```text -NativeCaptureTarget { hwnd: uint64, pid: uint32, creationTime: uint64, includeProcessTree: true } -NativeCaptureSession.start(target) - -> VideoFrames(WGC/DXGI) + AudioPcm(WASAPI process loopback) - -> existing native sender timeline/encoder/fanout -NativeCaptureSession.stop() -``` - -The first fixture should prove only parent-game plus child-game audio, an -independent voice process, a notification, process restart, no render stream, -and A/V sync on one Viewer. It should use Microsoft's Application Loopback -sample and process-loopback parameter contract rather than a custom mixer. - -## P1 First Slice: Audio Only - -The first product-wiring slice is deliberately smaller than the complete -native capture boundary. It adds an explicit Windows 11 -`window-process-audio` input while leaving the current Web video capture and -codec path unchanged. A small Windows helper resolves the selected target PID -and creation time locally, revalidates both immediately before activation, and -sends timestamped PCM through the authenticated loopback bridge. -The existing Chrome/Edge sender UI uses `AudioEncoder` with `codec: "opus"`, -48 kHz, two channels, and 20 ms frames, then returns the encoded Opus packets to -the existing Go sender. This reuses the current browser prerequisite and avoids -adding a native Opus library or a second package toolchain in this slice. - -The bridge is one typed local media envelope (audio kind, flags, QPC-derived -timestamp, duration, bounded payload); it does not add a public signaling field. -The Go session owns one shared Opus `TrackLocalStaticRTP` and adds it before the -offer on every native PeerConnection. Audio packetization uses the 48 kHz RTP -clock and the source timestamp delta, so one encoded packet stream can feed the -existing two-edge cap. In this historical audio-only phase, the WGC/DXGI video -adapter was the next small slice; its retained `SystemRelativeTime` let the -completed native-video path share the same QPC origin. - -Expected implementation size is about **500--800 new LOC**, excluding the -browser/Windows SDK and existing video/MF fixture code: Windows loopback -capture and local IPC (250--400), Go audio envelope/timeline/track (150--250), -and UI lifecycle/status plus focused tests (100--150). The landed slice keeps -that boundary and is not a deployment switch. - -The two source clocks are both converted from 100-ns QPC: WGC -`SystemRelativeTime` and WASAPI `IAudioCaptureClient::GetBuffer`'s -`pu64QPCPosition`. Keep timestamps monotonic and derive audio duration from -the captured frame count (960 samples at 48 kHz for each 20 ms packet), not -from bridge arrival time. `TrackLocalStaticSample.Timestamp` is not the source -timeline control in the current Pion path; the audio fanout therefore uses the -same explicit RTP packetizer/timeline pattern as native video. - -The slice fails closed. Windows 10, unsupported WGC, picker cancellation, -activation denial, an exited/inaccessible target, `AUDCLNT_E_DEVICE_INVALIDATED` -or `AUDCLNT_E_SERVICE_NOT_RUNNING`, protected-content silence, and a missing -render stream produce `audio unavailable` or `audio silent`. There is no retry -to whole-system loopback. No administrator privilege is required, but the -helper must run in the interactive user's session and honor Windows consent and -privacy settings. PID, creation token, title, path, device identity, and PCM -remain local. - -The first smoke is one direct Viewer: a test window emits a known tone and -visual marker, while an independent voice process and notification emit -different markers. Require an Opus inbound track and rendered video/audio, -monotonic QPC timestamps, and a bounded A/V offset; verify the unrelated -markers are absent and that target exit/silence stops or asks without widening -capture. SFU/UDP, second Viewer, and endurance remain follow-up gates; paired -WGC video completion is recorded below. On 2026-08-21, 100 20-ms chunks isolated a 440 Hz target from -an independent 880 Hz process by 4017.8x. Chrome 151 then received one audio -track and 495 inbound Opus packets while video decoded/rendered 296 frames. - -## P1 WGC/MF Completion - -The paired native-video source reuses the proven hardware fixture rather than -adding a second encoder framework. WGC creates a capture item directly from the -bound HWND on the selected D3D11 device. The frame pool is free-threaded and is -recreated on content-size changes. D3D11 VideoProcessor performs aspect-fit -BGRA-to-NV12 conversion at fixed 1280x720/30, then the existing adapter-bound, -hardware-only asynchronous MF H.264 path emits Annex-B `42c01f` access units. -There is no software MFT, alternate codec, monitor, or browser-video fallback. - -The helper multiplexes process-tree PCM, H.264 access units, and bounded local -status. Go writes H.264 directly to the existing shared fanout; the browser UI -receives only a one-way local copy for `VideoDecoder` preview. PCM retains the -existing WebCodecs Opus bridge. PLI/FIR recovery is generation-bound and -coalesced into a helper key-frame command. The normal browser source uses its -independent Browser codec path; the native H.264 source remains an explicit mode -and is not an implicit runtime downgrade. +1. Browser capture asks for window audio on window surfaces and system audio on + monitor surfaces where those hints exist. The Browser picker and consent + remain authoritative; older Browsers may ignore the hints. +2. Missing audio never blocks video-only sharing and is reported explicitly. + Track presence does not prove its source or isolation. +3. Returned screen-audio tracks use `contentHint = "music"`. Current bounded + sender ceilings and narrow Opus stereo answer normalization are owned by + [media quality](../product/media-quality.md). There is no Web Audio mixer, + resampler, second representation, or independent audio clock. +4. Browser source switching replaces the current screen-audio track in the same + persistent media stream. Local Viewer play, mute, and volume stay inside the + native media element. +5. Do not describe generic Browser system audio as application-isolated. UI and + documentation must leave source scope to the Browser's actual result. +6. Windows process-tree audio, WGC/MF video, helper protocols, and evaluation + packaging remain Native research. Their product stop line is + [ADR-0006](./0006-fixed-high-native-sender-canary.md); measurements are in + [native sender evidence](../research/native-sender.md). ## Consequences -- Current Web users get a useful Chromium hint and an honest status without a - privacy claim the platform cannot support. -- Strict per-window audio is a native Windows 11 feature, not a browser promise - or a silent system-audio fallback. -- The helper is packaged beside the sender in a commit-bound evaluation ZIP. - The ZIP carries internal and external SHA-256 manifests plus exact linked Go - dependency license files; it is neither an installer nor a release. +Positive: -## Remaining Evidence Boundary +- Web capture stays on standard permission and track APIs; +- audio absence and privacy scope are represented honestly; and +- current audio quality controls do not require another media graph. -- A second Viewer, SFU/UDP, endurance, and real-game A/V sync remain unproven; - the one-Viewer H.264 and attributed `VideoEncode` gate passed. -- The short-lived Windows x64 evaluation artifact is retained evidence, not an - instruction to download or run it. Native release work remains at the bottom - of the single project TODO ledger and still requires license, signing, and its - own acceptance boundary. +Negative: + +- Browser/system combinations remain inconsistent; +- Web cannot promise per-process isolation; and +- strict application-audio capture requires a later native product decision. + +## Primary Sources + +- [Screen Capture](https://www.w3.org/TR/screen-capture/) +- [Media Capture and Streams](https://www.w3.org/TR/mediacapture-streams/) +- [Windows application loopback](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/) +- [Windows Graphics Capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture) +- [WASAPI process-loopback activation](https://learn.microsoft.com/en-us/windows/win32/api/audioclientactivationparams/ns-audioclientactivationparams-audioclient_process_loopback_params) diff --git a/docs/deployment.md b/docs/deployment.md index 4b923fe..e85e7ad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,572 +1,115 @@ -# Minimal Deployment +# Application Release And Recovery -Last verified: 2026-08-27. +This file owns routine immutable application releases. Initial service setup is +in [self-hosting operations](./operations/self-hosting.md); environment and ports +are in [configuration reference](./reference/configuration.md); exact current +production identity is owned only by [status](./status.md). -This page records production running exact deployed application/runtime revision -`73ed920a229bfc64c89b97045c00d6cd75216f3a`, release `73ed920`, and the single -Browser `screener-v13` contract. Product direction and pending work are owned by -[project memory](./project-memory.md) and [the TODO ledger](./todo.md). +The tracked `release-app.sh` is the updater for the current bare-metal nginx, +LiveKit, coturn, nftables, and local-port-8787 deployment. It requires an +existing current release. Another proxy, optional-LiveKit shape, firewall owner, +application port, or first installation needs its own scoped bootstrap/updater; +do not call this wrapper generic. -This section documents the accepted UDP-only deployment contract: one Node.js -process provides the built Web client, room API, and WebSocket signaling behind -Caddy or nginx; ordinary peer ICE advertises only STUN, while Browser LiveKit -PCs configure no external ICE server and retain LiveKit-signaled UDP candidates. -LiveKit supplies bounded SFU capacity. Normal media remains distributed -through direct or peer edges whenever those paths work. Production includes -Browser SFU ICE isolation and leaves representation construction and selection -to pinned LiveKit with Dynacast and server send-side BWE. +## Release Boundary -A deployment may additionally provide one dedicated single-node LiveKit process -as the current controller's automatic final media fallback. This capacity is -dormant unless the complete public URL, private API URL, key, secret, and capacity -tuple is configured. It does not replace the P2P path, -the peer-assisted experiment, or required STUN discovery. LiveKit remains -ICE/UDP only. Ordinary peer ICE remains STUN-only and production coturn uses the -tracked STUN-only configuration with TCP/TLS disabled. The source and production -configure no TURN, ICE/TCP, media TCP, or TLS-relayed media. +A routine application release changes only the built Screener client/server and +its independent runtime dependency tree. It does not change infrastructure, +service units, proxy/firewall rules, secrets, LiveKit/coturn configuration, or +persistent room state. -Production runs exact `73ed920a229bfc64c89b97045c00d6cd75216f3a`, release -`73ed920`, from `/opt/screener/releases/73ed920`. The immutable runtime tar -SHA-256 is -`351053e497e8eee17956f4cec86305b05d805c80deb40b2cf4cd2668eaf08583`. -Its 41-file path/size/hash manifest SHA-256 is -`bb993950a30a0b99b9b6185aa13777edcccbdcdaebdfb1b05b3c3499a1e78479`. -Local and public `/healthz` return 200; Screener, LiveKit, coturn, and nginx are -active with `NRestarts=0`. The public main Browser asset is -`assets/index-CaM3Pn5_.js` with SHA-256 -`3c64cd75a7d67716f7600465a1742b1c02f3b00e71a1039649604aaf6d08f816`. +Any task that touches one of those excluded surfaces must first record its exact +pre-change value and a recovery procedure for that surface. Do not complicate the +routine application wrapper with one-off infrastructure branches. -The release deploys the single Browser `screener-v13` wire and one internal, -content-independent H.264 sender gate with VP8 fallback across Browser direct, -browser-relay, and SFU video. It retains pinned LiveKit default screen-share -representations, Dynacast and server send-side BWE without an application layer -selector, video `contentHint = "motion"`, and a local share-locked -`VP8 | Auto | H264` Host selector without codec wire state. Random four-digit -rooms use a 24-hour dormant lease, one room-lived 22-character grant, -orthogonal `open | private` grant/code admission, -20-Viewer room admission, the uniform endpoint media-copy cap `2`, and the -one-controller exact-candidate route runtime. Production configures -`ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite`; systemd owns that single -`0700` StateDirectory and the database is `0600`. Only room authority survives -an application restart; participants, routes and media rebuild from fresh -process state. -LiveKit is dedicated, has `room.auto_create: false` and -`max_participants: 21`, and the application derives deployment admission as -9,000 publication ingress plus `9000 * MAX_VIEWERS_PER_ROOM` subscription -egress handles. Coturn listens only on UDP 3478 for STUN; -LiveKit media listens on UDP 7882. The stable application firewall table -`inet bonfire_filter` has SHA-256 -`afaf9d066e9f292d8bd19032b38c0978ccf60dd01c2fe7f210968ff751f90534`, -and `/etc/nftables.conf` has SHA-256 -`d57cab70b455e6ddd5cd4688c354b9af5e1d4fb9e9b67c6b85ade0da347cb1d0`; -the whole live ruleset is not a stable identity because fail2ban owns a dynamic -address set. -These bounds are fail-safe admissions, not throughput or quality claims. -The retained local 20-Viewer Browser smoke and open real-network boundaries are -owned by [verification status](./verification-status.md). +The pre-cutover application release is guaranteed only through bounded health +and postflight. It has no retention contract afterward and is not a maintained +backup; Git and immutable artifacts own history. -## Topology and prerequisites +## Build Host -Use two DNS names, for example `share.example.com` and `stun.example.com`. They -may resolve to the same public IP: the Web ingress owns TCP 443 while the -self-hosted STUN listener owns UDP 3478. - -```text -browser -- HTTPS/WSS --> Caddy or nginx :443 --> Node.js :8787 -browser <------------ DTLS-SRTP P2P ------------> browser -browser -- ordinary-peer STUN/UDP --> coturn :3478 -browser <---------- DTLS-SRTP/UDP ----------> LiveKit :7882 -``` - -The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched -release. Provision a valid TLS certificate for the Web name. Enable operating system time -synchronization and keep the Node application port reachable only from its -reverse proxy. A single Node process is intentional. Participants, signaling, -routes and media remain in process memory; optional SQLite preserves only room -authority and does not restore live WebRTC connections. - -Build and validate the exact revision on a build host before starting it: +Start from a clean exact revision with Node.js 24 and npm 11: ```sh npm ci npm run check -npm start +npm run build +node scripts/package-app-release.mjs ``` -Do not run the repository full check, typecheck, or build on the current 960 MiB -no-swap production host. An inactive-release check before the `261e980c` cutover -temporarily starved HTTP and SSH; exact `691863e` remained current and all four -services stayed at `NRestarts=0`. The retained production path uses a locally -verified dist artifact plus one `npm ci --omit=dev --ignore-scripts --no-audit --no-fund` -in a transient unit bounded to 30% CPU, 160 MiB memory high, 256 MiB memory max, -no swap, and 120 seconds. It then runs only runtime imports and the -artifact/inode/permission gates. +The packager refuses a dirty tree, records the full revision, emits a runtime +archive plus path/size/SHA-256 manifest and release descriptor, and +extracts its own artifact to verify it. Upload the archive, manifest, and +descriptor together to `/opt/screener/uploads`. -Each immutable release must own an independent dependency tree. Never hard-link -`node_modules` or another file that deployment may `chown`, `chmod`, replace, or -remove: metadata changes would also mutate another immutable release. A copy or -copy-on-write reflink is acceptable only after an inode audit confirms that the -old and new regular-file sets have zero shared inodes. Do not recursively change -permissions until that check passes. +Do not build or run the full repository check on a constrained production host. +The release wrapper installs only production dependencies in a transient, +CPU/memory/time-bounded unit. -Routine application releases use the tracked -[`package-app-release.mjs`](../scripts/package-app-release.mjs) build-host entry -and [`release-app.sh`](../deploy/release-app.sh) server entry. The packager accepts -only a clean exact Git revision and already-built `dist`, emits one normalized -`tar.gz`, an exact path/size/hash manifest, and a non-secret release descriptor, -then extracts and verifies its own result. The archive also carries the full Git -revision. The server entry accepts only that descriptor, verifies the uploaded -hashes and revision, extracts into a new release directory, compares the exact -regular-file set with the manifest, installs an independent runtime dependency -tree under the existing resource bounds, and performs the atomic cutover and -bounded recovery checks. Release-specific scripts must not be copied and edited. +## Atomic Cutover -Infrastructure, configuration, secrets, and persistent state are outside this -application wrapper. A release that changes one of those surfaces uses a separate -scoped transaction with its own pre-change value and recovery path; it must not -make the routine application primitive conditional on that one-off change. - -A routine application-only cutover leaves infrastructure, configuration, secrets, -and persistent state untouched. Verify a new immutable artifact, switch to it -atomically, and guarantee the pre-cutover application release only through -bounded health and postflight checks. It has no retention contract afterward and -is not a maintained backup. When a task actually changes infrastructure, -configuration, secrets, persistent state, or an irreversible surface, define and -verify recovery only for the touched surfaces before changing them. - -In a strict-shell deployment, expected service states are data, not command -failures. Do not call `systemctl is-active` bare under `set -e`/`ERR`: an -intentionally stopped service returns a nonzero status and can trigger a false failure path. Read -`ActiveState` with `systemctl show`, compare the returned string explicitly, and -keep stop, symlink switch, start, health check, and any release restoration as -separate steps. Preflight every inspection dependency before changing a -service. After a start, poll health for a short bounded window instead of treating -one request during startup as a -failed release; retain the last failure while still enforcing the deadline. -After starting coturn, likewise wait boundedly for its UDP 3478 socket before -asserting STUN readiness; `ActiveState=active` can precede socket readiness. - -Run `npm start` under a service supervisor that injects the environment, restarts -on failure, and applies bounded logs. For a simple untracked environment file, -the equivalent direct launch is -`node --env-file=.env.production dist/server/server/index.js`. Do not expose port -8787 to the Internet. The application default is `0.0.0.0` for LAN development -and container compatibility, while the bare-metal production baseline below -explicitly sets `LISTEN_HOST=127.0.0.1` because only the same-host reverse proxy -should connect. A container can instead use `0.0.0.0` and enforce the intended -boundary with port publishing rules or a host firewall. - -The optional same-host LiveKit layout needs no additional public hostname: -`LIVEKIT_URL=wss://share.example.com` and nginx proxies only current `/rtc/v1*` -requests to LiveKit. Retired `/rtc` and `/rtc/validate` endpoints return 404 -without request-target logging. LiveKit's HTTP/WebSocket listener on TCP 7880 is -private to nginx and the Screener process uses -`LIVEKIT_API_URL=http://127.0.0.1:7880` for `RoomService`; WebRTC media reaches -LiveKit directly on UDP 7882. No unrelated application may create rooms on this -instance, and its tracked configuration sets `room.auto_create: false`. -Install -[`screener-livekit-readiness.conf.example`](../deploy/systemd/screener-livekit-readiness.conf.example) -as a `screener.service.d` drop-in on this layout. `After=` orders the services, -while the bounded `ExecStartPre` waits for TCP 7880 readiness; ActiveState alone -does not prove the LiveKit control listener is accepting connections. - -## Candidate boundary and recovery - -The accepted target for `share.bonfire.icu` remains distributed and automatic: -direct/peer UDP first, with one Host publication available to authorized SFU -subscriptions when a logical edge cannot use peer transport. SFU-fed and -peer-fed endpoints use the same provisional-child transaction, and the exact -candidate child's first newly decoded frame commits that edge. Server-assisted -subscriptions and allocations use independent deployment admission rather than -an endpoint-cap or fixed-root rule. - -The repository requires STUN and ordinary peer connections receive STUN-only -ICE. An existing logical edge prefers direct/peer UDP; the only application -suffix is the Host publication/SFU path. LiveKit participants receive only -revision-bound `sfu-config` URL/token messages and negotiate within LiveKit's -separate UDP-only ICE domain. The accepted Browser publisher and subscriber -connect contract provides an explicit empty ICE-server list, retains the UDP -candidates delivered through LiveKit signaling, and lets standard ICE select a -nominated non-relay pair. Candidate type, count, address family, and Browser -socket allocation remain implementation observations. This does not remove -ordinary-peer STUN or the deployment STUN used by LiveKit to discover its own -public address. - -`PEER_ASSISTED_MEDIA=true` is the process-wide topology/SFU switch. Every normal -room gets its own bounded controller state and ordinary peer connections remain -STUN-only. Run route or infrastructure candidates on an isolated -instance/hostname and keep the current release unchanged. If the candidate fails, -stop it and restore only the recovery boundary defined for its changed surfaces; -do not add a permanent dual-transport branch. - -A memory-room release starts with empty process authority. Starting the candidate -invalidates every current production room, Host token, invitation, password -verifier, and route; open pages must refresh or explicitly create a replacement -room. No room state crosses a release boundary in either direction. Guarantee the -pre-cutover immutable application release only through the bounded postflight; it -has no retention contract afterward and is not a maintained backup. An -application-only cutover leaves the environment, unit, LiveKit, coturn, firewall, -and reverse proxy unchanged; any task that changes one of those surfaces must -establish its scoped recovery before the change. - -A shared-public-IP instance can test candidate behavior, but it cannot prove the -clean-port boundary or approve broad migration. The room-1 smoke uses this -shape: nginx owns 443, LiveKit 1.13.5 owns UDP 7882, and LiveKit is fail-closed under a 192 MiB -high/256 MiB hard cgroup limit with restart disabled. Prefer a separate VM/IP -for the full gate and verify representative external networks and devices. - -Coturn provides STUN binding only and must reject allocation requests. -HTTPS/WSS always remains TLS/TCP independently of the UDP-only media ladder. - -## Production application environment - -Keep the real values in the process secret store or an untracked, access-restricted -environment file. Node.js 24 can load such a file with `--env-file`; the tracked -`.env.example` is only a schema. Generate independent secrets with a password -manager or a cross-platform Node command such as: +Run the tracked server entry with the uploaded descriptor: ```sh -node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" +sudo env SCREENER_PUBLIC_ORIGIN=https://share.example.com \ + /path/to/repository/deploy/release-app.sh \ + /opt/screener/uploads/screener-.release.json ``` -Use this production baseline template: +The wrapper: -```dotenv -NODE_ENV=production -LISTEN_HOST=127.0.0.1 -PORT=8787 -PUBLIC_BASE_URL=https://share.example.com -ALLOWED_ORIGINS=https://share.example.com -SITE_ACCESS_PASSWORD= -ROOM_LEASE_SECONDS=86400 -ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite -MAX_VIEWERS_PER_ROOM=20 -ENDPOINT_MEDIA_COPY_CAPACITY=2 +1. takes a deployment lock and validates the descriptor, revision, archive, + manifest, current release, required services, and public origin; +2. rejects unexpected archive paths, links, file types, or duplicate inodes; +3. extracts to a new release directory and verifies every file hash and size; +4. installs an independent `node_modules` tree under resource limits and checks + runtime imports plus production configuration; +5. proves the old and new releases share no regular-file inode; +6. atomically switches `/opt/screener/current`, starts the service, and polls + bounded local/public health; and +7. restores the exact prior symlink and service when cutover or health fails. -STUN_URLS=stun:stun.example.com:3478 -``` +Never hard-link dependency trees or recursively mutate permissions before the +inode audit: metadata changes would violate both releases. Expected service +states should be read as data (`systemctl show`), not used as bare commands under +strict-shell error traps. -To make automatic SFU fallback capacity available, add the complete tuple: +## Persistent State -```dotenv -PEER_ASSISTED_MEDIA=true -LIVEKIT_URL=wss://share.example.com -LIVEKIT_API_URL=http://127.0.0.1:7880 -LIVEKIT_API_KEY= -LIVEKIT_API_SECRET= -``` +Stable mode stores only room authority at the configured SQLite path. The +systemd service owns its state directory and file permissions. Application +restart retains room ownership, invitations, policy, password verifier, and +lease; participants, signaling, routes, SFU state, and media reconnect from fresh +process state. -Supplying any stale `PEER_ICE_TURN_*` or `SELECTED_EDGE_TURN_*` key, even blank, -fails startup. Production contains none of those keys, uses the tracked -STUN-only coturn configuration, and keeps TCP 3478/5349 plus every relay range -closed. An application-only release leaves the environment, coturn configuration, -and firewall unchanged; a task that changes any of them must define recovery for -that exact set before modification. +Lightweight mode has no persistent room state and starts empty. A release does +not add a migration or compatibility reader for either mode; current exact +schema validation fails closed before signaling or LiveKit mutation. -`PEER_ASSISTED_ROOM_IDS` is retired. Supplying it, even blank, fails startup so -that a stale room-1 deployment cannot silently retain the old scope. With -`PEER_ASSISTED_MEDIA=true`, every normal room receives peer-assisted routing, -optional LiveKit fallback, and the same deployment resource/fanout/failure guards. -Every ordinary peer connection remains STUN-only. There is no browser control, -percentage rollout, or second router. +## Postflight -`ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`SITE_ACCESS_PASSWORD` is required in production and must contain 8 through -128 visible ASCII bytes (`0x21` through `0x7e`). It must not be reused for -LiveKit, TLS, or another service. It authorizes room creation, Host role, -and code-only Viewer attempts; it does not replace a private room grant or -password. Local development and tests may omit it. Supplying a removed access -key, even blank, fails startup. -`ROOM_LEASE_SECONDS` defaults to 86,400 seconds and owns the dormant room -lifetime. An actively connected Host prevents expiry; explicit stop or Host -disconnect starts the lease, and only the exact Host token renews it before -expiry. Viewer activity never renews ownership. Optional `ROOM_DATABASE_PATH` -enables the exact-schema SQLite stable room-authority mode; unset keeps the -lightweight memory mode. `ROOM_TTL_SECONDS` remains invalid. -Production accepts 1 through 20 and explicitly selects 20. This is an -admission limit, not evidence that every publisher, network, or quality profile -can sustain that many streams. -`ENDPOINT_MEDIA_COPY_CAPACITY` defaults to 2 and accepts only 1, 2, or 3. It is -the single server-authoritative steady outbound media-copy cap for Host and -Viewer endpoints; role, browser, UA, and visibility do not create another tier. -A peer child or the Host's single SFU publication consumes one copy and upstream -receive is free. Transition work -may reach only `min(C + 1, 3)`. A Host already sending three copies at `C=3` -must fail or wait before a fourth endpoint copy is issued. -Supplying the removed `MAX_PEER_RELAY_DOWNSTREAM_EDGES`, even blank, fails -startup. +Verify only the surfaces relevant to the release: -Production release `73ed920` runs the deployed server and Browser assets -atomically on `screener-v13`; every stale Browser or executable-sender wire fails -before room authority. Native senders and helpers are outside this release. +- local and public `/healthz` return `{"status":"ok"}`; +- the public HTML references the new immutable main asset; +- Screener, nginx, coturn, and LiveKit have expected active/restart + state; +- the current symlink and served revision match the descriptor; +- room creation, Host authentication, one direct Viewer, and configured SFU + fallback complete the scoped smoke; and +- infrastructure, config, secrets, ports, and persistent state not named by the + task are unchanged. -The four `LIVEKIT_*` values must either all be absent or all be present, and a -complete tuple requires `PEER_ASSISTED_MEDIA=true`. The four-digit code space -fixes ingress at 9,000 Host publications; egress is 9,000 multiplied by -`MAX_VIEWERS_PER_ROOM`. There is no separate SFU capacity configuration. An -empty tuple keeps the optional SDK and server path dormant. `LIVEKIT_URL` must be a -plain `ws:` or `wss:` origin with no `/rtc` suffix; production requires `wss:`. -`LIVEKIT_API_URL` must be a plain `http:` or `https:` origin with no path; -production permits plaintext only on loopback and otherwise requires `https:`. -`LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse -`SITE_ACCESS_PASSWORD`. These credentials authorize short-lived LiveKit room -tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU -media. The LiveKit instance is dedicated to this Screener process and has -`room.auto_create: false`. Screener first binds the configured application -listener exclusively; a competing process that cannot bind makes no LiveKit -call. While bound but not initialized it returns `503` and has no signaling -upgrade handler. It then rejects any foreign room name, deletes every stale -managed room, confirms the namespace empty, and begins serving. Each -exact-generation room is then created before its token is issued. Reserved, -committed, and draining generations remain charged until -`DeleteRoom` succeeds and a follow-up lookup proves absence. Run only one -application process until a shared atomic admission and lifecycle owner exists. +Representative network, mobile, audio, 20-Viewer, and endurance evidence belongs +to [verification status](./verification-status.md), not routine postflight. -For the first candidate canary, use an isolated instance and one recorded room. -Restart the application, verify startup removes the now-stale managed LiveKit -room before serving traffic, and create a fresh application room after startup. -Hold the application port with another process and verify a competing -startup makes zero LiveKit calls. Verify that both the replacement room and a -second normal room contain -`mediaMode: "peer-assisted"`, with independent route revisions and no shared -room state or capacity bypass. Exercise join, offer and answer, stale-token -reconnect after abort, Host signaling loss with and without a live Host -participant, stop, restart, and room deletion in both rooms. Confirm each drain -keeps capacity charged until the room is absent. Ordinary Web clients remain -STUN-only. Roll back by stopping Screener, proving every managed LiveKit room -absent, and restoring the exact recorded application environment, application -release, LiveKit configuration, coturn configuration, and firewall snapshot as -one unit; or direct traffic to the unchanged old instance. During migration, -remove retired route configuration in the same coherent change; the process -flag explicitly enables the all-room controller. +## Recovery -Only `POST /api/site-access` accepts the site access secret in an -`Authorization: Bearer` header from an exact allowed Origin. Success returns a -12-hour stateless HMAC-SHA256 cookie with `HttpOnly`, `SameSite=Strict`, -`Path=/`, bounded `Max-Age`, and, under production HTTPS, `Secure` plus an -`__Host-` name. The cookie contains no account or server-side session ID. There -is no account database, JWT, session map, or logout endpoint. +Before cutover, the wrapper keeps the exact prior release path. On failure after +the switch begins, it stops Screener, restores that path atomically, restarts the +service, and waits for health. A failed recovery exits distinctly and requires +operator intervention; it must not silently report the new release as active. -Before an access-boundary release, set `CHROME_PATH` to a local Chrome or -Chromium executable and run `npm run gate:access-privacy`. The gate creates a -loopback in-memory room with an isolated browser profile, navigates the fragment -through CDP rather than a process argument, disposes that context, and emits only -fixed booleans and counts. It checks tracked request/nginx/application logs, -browser storage, fragment cleanup, absence of a persistent room file, and runs -the complete signaling suite containing the strong rotate/revoke teardown case. -It never connects to production or accepts a production credential; exact -production request/nginx/journal inspection remains separate. - -nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and -returns 429 when exhausted. The limiter uses nginx shared memory; do not add the -secret, Authorization header, request body, or a new source-address field to -logs. `POST /api/rooms` accepts only the site-access cookie and strict JSON -with an explicit code-entry policy and optional room password; a Bearer header -is not an alternate creation -path. `/signal` still admits a cookie-free browser after Origin and capacity -checks and records the cookie state at upgrade. Host requires it with the room -Host token. A Viewer may omit it only when presenting a valid, unexpired grant -for that exact room; otherwise the server rejects before evaluating code entry. - -Every room receives an independent invitation -`/r/{code}#v={room-scoped-grant}`; the fragment does not enter HTTP or WebSocket -request targets. The page validates it, writes it only to that room's -`sessionStorage`, and immediately replaces the visible URL with `/r/{code}`. -The Host may update or revoke that grant without changing code-entry policy. - -Room allocation and lifetime use one deliberately small model: - -- random unallocated codes come only from `1000` through `9999`, never duplicate - an active code, use the fixed 9,000-room capacity, and return on room release; -- active Host sharing does not expire; stop, capture-track end, or Host - disconnect starts `ROOM_LEASE_SECONDS`; -- only the exact Host token resumes and renews before expiry, Viewer activity - cannot, and expiry invalidates the code, credentials, verifier, participants, - and routes; and -- lightweight-mode process restart has the same fail-closed effect; stable mode - restores only unexpired room authority and requires fresh sessions/routes. - -The server keeps only digests/verifiers for Host tokens, Viewer grants, and -optional room passwords. In stable mode those room-authority values, policy, -authorization generation and lease use one exact SQLite schema; live members, -routes and SFU state remain memory-only. The same browser keeps its raw Host token, -creation profile and non-expiring preferred code locally. Production stable-mode -uses the service-owned `/var/lib/screener` StateDirectory. Any new activation or -path change requires a separately verified writable directory plus configuration -and database backup/restore boundaries before changing `ROOM_DATABASE_PATH`. - -Production startup requires one to eight syntactically valid `stun:` URLs -before the server listens. This validates shape only; it does not prove DNS, -firewall, NAT mappings, or external STUN reachability. The current application -rejects `TURN_URLS`, `TURN_SHARED_SECRET`, or `TURN_CREDENTIAL_TTL_SECONDS` when -any key is present, including with an empty value. Remove those obsolete keys -from the candidate environment; startup fails instead of pretending they enable -compatibility. - -The rejected `PEER_ICE_TURN_*` and `SELECTED_EDGE_TURN_*` candidate names must -remain absent from production and fail startup even when blank. Ordinary peer -edges remain STUN-only and the application has no TURN configuration surface. - -## HTTPS and WSS ingress - -Terminate public TLS at Caddy or nginx and proxy every application path to the -single Node process. `/signal` must preserve the WebSocket upgrade, `Host`, and -`Origin`, and its idle/read timeout must be longer than a normal sharing session. -Do not cache `/api/*` responses. Caddy's minimal same-host form is: - -```caddyfile -share.example.com { - reverse_proxy 127.0.0.1:8787 -} -``` - -An equivalent nginx location is: - -```nginx -location / { - proxy_pass http://127.0.0.1:8787; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 1h; -} -``` - -The tracked -[`share.bonfire.icu.conf.example`](../deploy/nginx/share.bonfire.icu.conf.example) -follows this simple model and listens directly on public TCP 443. It does not -need an SNI transport router. Exact `/rtc` and pinned-v0 `/rtc/validate` requests -return 404 with access and request-line error logging disabled; only `/rtc/v1*` -is proxied. Current versioned requests carry a short-lived LiveKit JWT in the -query string, so no `/rtc*` target may fall through to the site's ordinary logs. - -When Certbot manages the Web certificate, install the tracked -[`reload-nginx.sh`](../deploy/certbot/reload-nginx.sh) as an executable under -`/etc/letsencrypt/renewal-hooks/deploy/`. Run `certbot renew --dry-run` after -the first certificate is installed. This hook reloads only the Web ingress. - -Keep the proxy's access-log retention bounded and access controlled. Requests to -`/r/{code}` put the room code in the path, so access logs can contain room codes -as well as network metadata. They must not be treated as public artifacts. For -a private room, the grant remains in the fragment and is not part of that -request target. - -For a process-level liveness probe, send `GET /healthz`. A running process -returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other -methods return 405 with `Allow: GET`. The endpoint does not inspect room state, -STUN/SFU reachability, or any external dependency, so use it only to decide whether -the Node process can accept HTTP requests. It is not a deployment-readiness or -end-to-end media check. - -## Optional single-node LiveKit fallback - -Use the tracked -[`livekit.yaml.example`](../deploy/livekit/livekit.yaml.example) and -[`livekit.service.example`](../deploy/systemd/livekit.service.example) as the -minimal single-node baseline. Install a reviewed, pinned LiveKit Server release -from its official release assets, generate an independent key pair with -`livekit-server generate-keys`, and place only the YAML key mapping in -`/etc/livekit/keys.yaml`. Keep that file owned by the `livekit` service account -with mode `0600`; put the same values in Screener's untracked process secrets. - -The example deliberately omits Redis and every recording, ingress, egress, -webhook, external-TURN, and embedded-TURN service. It explicitly sets -`tcp_port: 0` and `allow_tcp_fallback: false`, and points `stun_servers` at the -deployment's self-hosted STUN listener for server-side public-IP discovery and -to prevent pinned LiveKit from substituting a public default in its join -response. Current Screener source gives Browser publisher/subscriber connects an -explicit empty list, so those SFU PCs do not use either response after the -pending application cutover. The tracked `rtc.congestion_control` enables -`use_send_side_bwe` for LiveKit-owned per-subscriber simulcast selection. Do not -add Redis for this one-node workload. -The service journal is the diagnostic log; keep its retention finite and access -restricted. Pinned LiveKit 1.13.5 includes raw PublisherOffer SDP in info-level -join records, so the tracked production baseline uses `logging.level: warn` and -`pion_level: error`. Raise either only in an isolated, short-lived canary and -remove its journal afterward. Never persist JWTs, SDP, ICE candidates, API -secrets, or full `/rtc` and `/rtc/*` request targets. - -Configure both the host firewall and the provider firewall independently: - -| Destination | Protocol | Exposure | Purpose | -| --- | --- | --- | --- | -| `127.0.0.1:7880` | TCP | Private | nginx to LiveKit signaling/API | -| public host `:7882` | UDP | Public | WebRTC ICE/UDP mux | - -LiveKit may listen beyond loopback while it discovers and advertises public -media addresses, so the public-firewall denial of TCP 7880 is mandatory; nginx -must be its only ingress. UDP 7882 is a direct media listener and must not be -placed behind the HTTP reverse proxy. If the host is behind NAT, forward it -without port translation and verify the advertised candidate from an external -network. UDP-blocked networks are expected to fail clearly in this candidate; -do not set `rtc.tcp_port` above zero, enable TCP fallback, or add TURN to make a -canary pass silently. - -## Self-hosted STUN - -This subsection describes the accepted and deployed STUN-only boundary. -Production uses the tracked configuration and keeps TCP 3478/5349 plus every -relay range closed. - -Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf.example) -to an untracked service-owned location and use the tracked -[`coturn.service.example`](../deploy/systemd/coturn.service.example). The config -uses coturn's documented `stun-only` mode, ignores every TURN allocation request, -and disables TCP and TLS client listeners. It has no auth secret, user database, -relay port range, certificate, quota, or TURN REST credential. Pinned coturn -4.17.2 deprecates `no-dtls`, so the tracked config does not use that obsolete -switch or claim a separate DTLS listener gate. - -On a single-homed public host, automatic listener selection is sufficient. On a -multi-homed host, set `listening-ip` to the intended public interface. Open only -UDP 3478 and keep TCP 3478/5349 plus every old relay range closed. The same public -IP may serve HTTPS/WSS on TCP 443 and LiveKit media on UDP 7882. - -The example writes informational logs to stdout. Apply finite supervisor -retention and restrict access because STUN sees client IP addresses. Do not -enable `verbose` or `log-binding` continuously. `stun-only` is a hard candidate -boundary, not a hidden unauthenticated TURN service. - -Open only these public listeners: - -| Destination | Protocol | Purpose | -| --- | --- | --- | -| `share.example.com:80` | TCP | HTTP redirect and ACME challenge | -| `share.example.com:443` | TCP | HTTPS and WSS | -| `stun.example.com:3478` | UDP | STUN binding only | -| host public address `:7882` | UDP | LiveKit WebRTC ICE/UDP mux | - -## Verification - -Run these checks from real external networks before closing route acceptance: - -1. Open the official [Trickle ICE sample](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/) - and test the configured STUN URL. It must produce an `srflx` candidate over - UDP and no `relay` candidate. Confirm TCP 3478/5349 and the old relay range - are closed externally. -2. Create a normal Screener room on two different networks. Confirm media flows - and the selected-pair stats report a non-relay path when direct ICE succeeds. -3. On a normal room, exhaust a peer route and verify exactly one Host - publication serves only the admitted SFU subscriptions over LiveKit UDP 7882. - Peer descendants stay on ordinary direct UDP and every non-server endpoint - obeys the configured `ENDPOINT_MEDIA_COPY_CAPACITY`. On each Browser SFU PC, - verify no local external STUN/TURN server is configured, a nominated - non-relay UDP pair reaches an authorized LiveKit-signaled SFU candidate, and - the exact Viewer presents a decoded frame. Do not gate on candidate type or count. -4. Block all UDP and verify bounded recovery ends clearly without ICE/TCP, - TURN, or a long pseudo-connected path. -5. Exercise root departure, reconnect, SFU unavailable, route prepare rollback, - stop, and source/profile changes. Unaffected subtrees must not migrate. -6. Repeat at 1, 3, 5, and 20 viewers across representative consumer networks. - Record selected protocol, RTT, bitrate, frame rate, packet loss, publisher - upload/encode load, LiveKit ingress/egress, and final decoded quality. A real - public-network 1:20 session remains unverified; the passed local synthetic - 1:20 smoke does not replace this heterogeneous-network quality gate. - -References: [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2), -[pinned turnserver documentation](https://github.com/coturn/coturn/blob/4.17.2/README.turnserver), -the [pinned example configuration](https://github.com/coturn/coturn/blob/4.17.2/examples/etc/turnserver.conf), -[RFC6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/), -[LiveKit firewall guidance](https://docs.livekit.io/deploy/admin/firewall/), and -the [pinned LiveKit configuration sample](https://github.com/livekit/livekit/blob/v1.13.5/config-sample.yaml). +Infrastructure or persistent-state recovery is separate and limited to the +surfaces changed by that task. Do not restore an entire server, old firewall, or +old secret set for an application-only failure. diff --git a/docs/maintenance.md b/docs/maintenance.md index f52d2cd..e9edd1e 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -12,34 +12,34 @@ | --- | --- | --- | | 每次都必须遵守的硬约束和导航 | `AGENTS.md` | 保持短、单义、稳定;不复制流程手册或临时状态 | | 分支、研究、验证、PR、合并和清理流程 | `CONTRIBUTING.md` | 维护可执行的日常流程 | -| 当前产品事实和长期决定 | `docs/project-memory.md` | 就地更新,最多 200 行且不超过 16 KB | -| 当前生产、里程碑、阻塞项和明细索引 | `docs/status.md` | 只保留 current/index,最多 120 行且不超过 12 KB | +| 产品总览和领域导航 | `docs/project-memory.md` | 只保留每次 substantial task 都需要的摘要与链接 | +| 当前生产、里程碑、阻塞项和明细索引 | `docs/status.md` | 只保留 current/index;接近 120 行或 12 KB 时提示园艺审查 | | 当前未决、已授权或待用户决策的工作 | `docs/todo.md` | 不是完成记录;不把 checkbox、实验或 agent 建议自动升级为 TODO | -| 当前需求与设计 | `docs/需求理解.md`、`docs/方案设计.md` | 维护一个现行版本,变更历史交给 Git | +| 当前产品合同 | `docs/product/` | 每个领域一个 owner;只写行为、边界和非显然不变量 | | 架构取舍 | `docs/adr/` | 明确状态;新决定替代冲突旧决定 | | 可复核证据和研究结论 | `docs/research/` | 标日期、来源、推断、适用边界和许可证 | -| 当前验证与部署边界 | `docs/verification-status.md`、`docs/deployment.md` | 按需读取,不保存发布流水账 | +| 当前验证与部署边界 | `docs/verification-status.md`、`docs/deployment.md`、`docs/operations/`、`docs/reference/` | 按需读取,不保存发布流水账 | | 已完成工作的时间线 | commit、PR、issue | 不复制进常驻 memory 或 status | 每个事实只有一个 owner。顶层文件提供链接和当前结论,不复制深层正文;`docs/README.md` 维护完整文档索引。 ## 顶级指令卫生 -[官方调研](./research/agent-context-governance.md)的共同结论是:顶层指令应是短而稳定的地图,流程和专业细节按需加载,并定期删除冲突或陈旧规则。因此本仓库的 `AGENTS.md` 目标是约 50 行,而不是把任何公开上限当作填充目标。模块特有规则只有在对应代码存在且每次读取确有必要时,才放到最近目录的 `AGENTS.md`;流程说明进入 `CONTRIBUTING.md`,当前工作进入 `docs/todo.md`,证据与理由进入其 owner。 +[官方调研](./research/agent-context-governance.md)的共同结论是:顶层指令应是短而稳定的地图,流程和专业细节按需加载,并定期删除冲突或陈旧规则。因此本仓库的 `AGENTS.md` 保持在 100 行内,而不是把公开上限当作填充目标。模块特有规则只有在对应代码存在且每次读取确有必要时,才放到最近目录的 `AGENTS.md`;流程说明进入 `CONTRIBUTING.md`,当前工作进入 `docs/todo.md`,证据与理由进入其 owner。 ## 真相更新顺序 1. 按 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 authority 和 scope 规则,对照完整产品模型、当前代码、测试和证据确定目标;语义未定时只冻结依赖项。 -2. 就地更新受影响的 requirement、design、ADR 或 research owner;仅在快照确实变化时同步 memory/status,未决执行项进入 TODO。 +2. 就地更新受影响的 product、ADR 或 research owner;仅在快照确实变化时同步 memory/status,未决执行项进入 TODO。 3. 检查各 current owner 一致并形成 Git-tracked truth checkpoint,再开始依赖该结论的实现或派发。 -语义仍未决定时,在 TODO 记录 hold,只冻结依赖项。接受的真相先进入 canonical `main`;保留候选再从该精确提交 rebase 或重建一次,只移植获批的 scoped code、tests 和 new facts。旧分支的 truth 文档不得覆盖当前 owner。具体 Git 和 worktree 操作遵循 `CONTRIBUTING.md`。 +语义仍未决定时,在 TODO 记录 hold,只冻结依赖项。具体 Git、worktree、checkpoint 和 integration 流程由 `CONTRIBUTING.md` 拥有。 ## 机制减负审查 机制减负遵循 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 scope 和 smallest-correct-result 规则。本仓库的审查先只读清点机制服务的用户行为,量化移除后的真实退让,并与代码、配置、迁移、测试、运行时状态、部署和故障面的整体成本比较。文档也是需要同步和审查的机制:长期真相只保留产品合同、非显然不变量、算法/模型、关键取舍和外部证据,普通 UI 与实现细节由代码、测试和 PR 持有。若 owner 接受有界退让,先更新产品合同再删除失去必要性的完整表面;否则保留合同并寻找更小实现。审查不以行数为目标,也不授权无边界重构。 -应用层 TURN 清理是 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) 的机制减负实例;TURN 的完整成本证据由 [ICE/TURN 研究](./research/built-in-peer-ice-turn.md)维护。内存房间曾以有限恢复收益换取更小机制,但真实开发发布已证明旧邀请、撤销高水位和房间权威连续性构成一个内聚 SQLite 消费者,因此 [ADR-0002](./adr/0002-memory-resident-protected-rooms.md) 现在保留轻量与稳定两种模式。该变化说明减负结论必须随可达需求重审,而不是把一次删除推广为永不恢复该机制。 +TURN 的减负证据由 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) 和相关 transport research 持有;轻量/SQLite 房间取舍由 [ADR-0002](./adr/0002-memory-resident-protected-rooms.md) 持有。这些案例只证明应按当前消费者重审完整成本,不能被推广为永远删除或永远保留某类机制的规则。 ## 检查点与恢复 @@ -47,19 +47,20 @@ 检查点按 [`stop-that-shit`](../.agents/skills/stop-that-shit/SKILL.md) 的 current-result 规则,只保存以后仍需要的结论、当前状态、下一步和真实阻塞;不保存原始对话或工具输出、完整日志、临时路径、可廉价重查的事实和过程性 TODO 流水账。 -恢复上下文时依次读取 `AGENTS.md`、project memory、status、TODO、相关需求/设计/ADR/研究,然后检查当前分支、`git status`、近期 commit 和 PR。摘要与仓库冲突时,以当前代码、测试和权威文档为准。 +恢复上下文时依次读取 `AGENTS.md`、project memory、status、TODO、相关 product/ADR/research,然后检查当前分支、`git status`、近期 commit 和 PR。摘要与仓库冲突时,以当前代码、测试和权威文档为准。 ## 清理规则 - `project-memory.md` 和 `status.md` 接近预算时,先删除可从代码或 Git 恢复的完成/失效内容,再把仍需保留的细节迁到现有 owner;不要反复微压缩句子。 - `todo.md` 只保留真实未完工作、明确 hold 和待用户决策,不保留完成清单或一次性审计过程。 -- `verification-status.md` 只保留仍影响验收的跨模块证据;成熟结论迁回 requirement、ADR、research 或 deployment,过期结果删除。 -- 需求和设计不在末尾追加确认流水;研究失效时更新正文及最后验证日期;只有仍有解释价值的历史取舍进入 ADR。 +- `verification-status.md` 只保留仍影响验收的跨模块证据;成熟结论迁回 product、ADR、research 或 operations,过期结果删除。 +- Product 合同不追加确认流水;研究失效时更新正文及最后验证日期;只有仍有解释价值的历史取舍进入 ADR。 - 每个重要 PR 检查 current 文档是否互相矛盾、链接是否有效、memory/status/TODO 是否仍代表现在。 ## 验证与自动化 - 迭代中运行覆盖当前改动的最小定向检查;完整套件、真实浏览器矩阵、网络整形、跨设备、长时稳定性和部署检查在相关候选的验收边界批量运行。 -- 文档改动默认运行 Markdown、链接、whitespace、diff 和 repository-hygiene 检查;不因无关文档变化重跑媒体基准。 +- 文档改动默认运行本地 Markdown 链接、whitespace、diff 和 repository-hygiene 检查;不因无关文档变化重跑媒体基准。 +- 断链、缺失必需 owner 和格式损坏是 hard failure。每次注入的 `AGENTS.md`、repo STS skill 与只负责导入的 `CLAUDE.md` 也使用 hard context budget,避免规则稀释。Product、ADR、research、operations 等按需文档的行数/字节阈值只是 warning:它提示检查是否应拆分二级 owner、删除历史或移出过程信息,不要求为通过门禁压缩合理正文,也不是交付质量指标。 - 昂贵证据记录 commit、环境、结果和适用边界。只有相关路径、协议、配置、依赖、环境或门槛改变,或旧证据失败/含糊时才重跑。 - `.githooks/pre-commit`、`scripts/check-project-state.sh` 和 PowerShell 等价入口共享 `scripts/required-project-paths.txt`。Hook 只做快速、确定、可复现的检查;工程判断留给评审和测试。 diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md new file mode 100644 index 0000000..be26cf2 --- /dev/null +++ b/docs/operations/self-hosting.md @@ -0,0 +1,89 @@ +# Self-Hosting Operations + +This runbook owns initial single-host service setup. Application releases use +[deployment](../deployment.md); accepted environment values and ports are in +[configuration reference](../reference/configuration.md). + +## Topology + +```text +Browser -- HTTPS/WSS --> nginx :443 --> Screener :8787 +Browser -- /rtc/v1 WSS --> nginx :443 --> LiveKit :7880 +Browser <---------- DTLS-SRTP P2P ----------> Browser +Browser -------- ordinary STUN/UDP --------> coturn :3478 +Browser <--------- DTLS-SRTP/UDP ----------> LiveKit :7882 +Screener -------- private RoomService -----> LiveKit :7880 +``` + +The tracked public baseline uses Node.js 24, nginx, a valid Web TLS +certificate, time synchronization, and coturn 4.17.2 or a newer patched release. +LiveKit is required for the accepted SFU fallback and tracked release wrapper. +Web and STUN may use different DNS names on the same public IP. Another proxy or +P2P-only service shape needs its own matching updater and recovery checks. + +Expose only the public listeners in +[configuration reference](../reference/configuration.md). Screener 8787 and +LiveKit 7880 stay private. + +## Tracked Templates + +- [Screener systemd unit](../../deploy/systemd/screener.service.example) +- [LiveKit systemd unit](../../deploy/systemd/livekit.service.example) +- [LiveKit readiness drop-in](../../deploy/systemd/screener-livekit-readiness.conf.example) +- [coturn systemd unit](../../deploy/systemd/coturn.service.example) +- [STUN-only coturn config](../../deploy/coturn/turnserver.conf.example) +- [LiveKit config](../../deploy/livekit/livekit.yaml.example) +- [nginx site](../../deploy/nginx/share.bonfire.icu.conf.example) + +Copy templates outside the repository, inject independent secrets through an +access-restricted environment file or service manager, and adapt only addresses, +domains, certificates, users, and resource limits required by the host. + +## Initial Setup + +1. Configure DNS and TLS for the Web origin and STUN name. +2. Install Node.js, nginx, coturn, and pinned-compatible LiveKit. +3. Create an unprivileged `screener` service account, `/opt/screener/releases`, + `/opt/screener/uploads`, and an access-restricted environment file. +4. Provision one verified initial immutable release and atomically point + `/opt/screener/current` to it before enabling the unit. The tracked release + wrapper is upgrade-only and deliberately refuses a missing prior release; + this repository does not provide a generic first-install transaction. +5. When stable room authority is enabled, use the service-owned + `/var/lib/screener` StateDirectory and include the SQLite file in the host's + state backup/recovery policy. +6. Install the tracked service and proxy templates. Bind Screener to loopback on + bare metal; containers may bind `0.0.0.0` only when publishing/firewall rules + preserve the same boundary. +7. Apply the public-port allowlist and confirm every retired TURN/TCP port + is closed. +8. Start coturn and LiveKit before Screener, then nginx. The LiveKit + readiness drop-in waits for its private control listener before Screener. + +LiveKit must be dedicated to this Screener application, set +`room.auto_create: false`, and expose its control listener only to the proxy and +application host. Screener binds its own listener before touching LiveKit, +rejects foreign room names, removes stale managed rooms, confirms the namespace +empty, and only then installs signaling. A competing application process must +make no LiveKit mutation. + +Coturn runs `stun-only`, `no-tcp`, and `no-tls`. It must answer STUN binding and +reject TURN allocation. Avoid verbose STUN/ICE logging because infrastructure +necessarily observes client IP addresses. + +## Operational Verification + +- `/healthz` returns `{"status":"ok"}` locally and through the public origin. +- Screener, nginx, coturn, and LiveKit are active with expected restart + counts and bounded logs/resources. +- The official Trickle ICE sample obtains an ordinary UDP `srflx` candidate from + the configured STUN service and no relay candidate. +- A direct room presents media on two networks when Peer ICE succeeds. +- A constrained route creates one managed Host publication and only admitted + subscriptions over LiveKit UDP; Peer descendants remain ordinary P2P. +- Blocking all UDP reaches a clear bounded failure. +- Restarting Screener in stable mode retains room authority while Browser + sessions and media reconnect from fresh process state. + +Representative network, mobile, audio, 20-Viewer, and long-running evidence is +tracked in [verification status](../verification-status.md), not in this runbook. diff --git a/docs/product/media-quality.md b/docs/product/media-quality.md new file mode 100644 index 0000000..e79bacc --- /dev/null +++ b/docs/product/media-quality.md @@ -0,0 +1,103 @@ +# Capture, Audio, And Media Quality + +This file owns the current Browser media contract. Detailed measurements and +platform limits live in [realtime quality research](../research/realtime-quality-adaptation.md) +and [screen-audio research](../research/browser-screen-audio-quality.md). +[ADR-0007](../adr/0007-path-isolated-representation-quality.md) owns the SFU +adaptation decision. + +## Capture And Controls + +- The Web Host may share a display, application window, or Browser tab and may + stop, synchronously pause/resume audio and video, or switch source. +- Share and source-switch requests ask the Browser for available audio by + default. Missing audio is reported clearly but does not block video-only + sharing. +- Authoritative pause disables all current capture tracks while retaining the + room and established routes. Black frames, track mute, or network failure are + not interpreted as a user pause. +- The Host preview displays the capture stream directly and creates no Viewer or + media route. Hiding the page may pause only that local video element; it must + not intentionally stop capture, encoding, or upload. +- Live quality changes update the current capture constraints and sender + ceilings without reopening source selection or replacing a healthy route. + +## Video Profiles + +The three recommended profiles are ceilings, not delivery guarantees: + +| Profile | Target | Video bitrate ceiling | +| --- | --- | ---: | +| `720p30` | 1280x720 at 30 fps | 3 Mbps | +| `1080p30` | 1920x1080 at 30 fps | 5 Mbps | +| `1080p60` | 1920x1080 at 60 fps | 8 Mbps | + +`1080p30` is the default. Advanced resolution includes 480p, 720p, 1080p, and +1440p; frame rate, bitrate, and `maintain-resolution | balanced | +maintain-framerate` remain independent controls. Display video uses the standard +`contentHint = "motion"` for game motion. + +Current acceptance targets are: + +- on a controlled P2P path with RTT at most 40 ms and loss at most 1%, glass- + to-glass latency p50 at most 150 ms and p95 at most 250 ms; +- through a regional SFU/UDP root, glass-to-glass p95 at most 350 ms; and +- when the 60 fps profile is selected, no silent run of five seconds below 50 + decoded fps without an explicit adaptation or user-visible limitation. + +These are targets pending the physical matrices in verification status, not +claims that every Browser, game, or network already meets them. + +## Codec Decision + +The pre-share Browser selector is `VP8 | Auto | H264`, defaults to Auto, and is +locked while sharing. It is local UI state and is not persisted or carried as a +room quality setting. + +Auto uses one bounded actual-sender probe rather than a capability or device +allowlist. A proved sender prefers H.264 with VP8 fallback; unsupported, failed, +or inconclusive probes use VP8. Manual VP8 or H264 strictly selects that codec. +The result stays fixed for the current share and is reconsidered for a new share. +ADR-0007 owns probe and negotiation mechanics. + +The Browser chooses the concrete encoder implementation. A reported H.264 codec +does not by itself prove hardware acceleration, and Web content cannot select a +specific MFT, NVENC, AMF, or QSV implementation. + +## Framework-Owned Adaptation + +Each direct or relay PeerConnection keeps its own stock WebRTC congestion and +quality adaptation. The Host's one SFU publication uses the share-generation +codec and selected ceiling but no application-defined simulcast ladder or backup +codec. Pinned LiveKit defaults construct representations; Dynacast aggregates +demand and server send-side BWE selects subscriber forwarding. AdaptiveStream +stays disabled because any Viewer may relay its received track. + +Screener does not maintain an application bitrate/resolution ladder, scene +detector, periodic quality controller, manual SFU layer selector, or whole-room +lowest-common-denominator target. + +## Screen Audio + +Display audio uses `contentHint = "music"`. The live sender ceilings are 64, +128, and 192 kbps with 128 kbps default. Peer answers request Opus stereo and a +192 kbps receive ceiling. The SFU publication uses stereo, DTX disabled, and RED +disabled; this accepts less burst-loss resilience in exchange for bounded +publisher traffic. The Browser and source still decide whether an audio track +exists and what is actually delivered. + +## Observable Truth + +Requested resolution, frame rate, bitrate, content intent, codec, and audio +quality are inputs or ceilings. Sender parameter readback and current RTCStats +are the observable result. Connection detail may show actual path, codec, +resolution, FPS, bitrate, loss, jitter, RTT, and encode/decode behavior when the +Browser exposes them; missing or reset counters remain unknown. + +The current quality shadow accepts only current foreground presentation evidence +and never changes routes or media settings. Quality-driven topology work is +parked in [TODO](../todo.md). + +Web pages cannot guarantee Host background capture, mobile background playback, +or relay execution after Browser/OS suspension. Those remain physical lifecycle +boundaries, not keepalive features. diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md new file mode 100644 index 0000000..1224b3c --- /dev/null +++ b/docs/product/presentation-lifecycle.md @@ -0,0 +1,86 @@ +# Presentation And Lifecycle + +This file owns user-visible workflow, presentation authority, presence, and +Browser lifecycle promises. Component structure, CSS, exact copy, and routine UI +tests belong in source code. + +## Product Surface + +The first screen is the working tool, not a marketing page. Once site access is +available, starting a share and joining a room are equally discoverable actions; +joining expands the four-digit room form in place, while `/join` remains a direct +entry route. Users never choose a media topology. + +The Host workspace centers the capture preview, room code, invitation and common +share controls. The Viewer workspace centers one persistent 16:9 video stage, +room identity, current state, roster, and one manual media-reconnect action. +Controls and text must remain usable without overlap or horizontal overflow on +desktop and mobile viewports. + +## Playback Ownership + +One native `