# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). House discipline this file maintains (established by review W-min90/91/92): - `## [Unreleased]` carries **one section per type** (`### Added`, `### Changed`, `### Removed`, `### Fixed`, `### Security`) — a batch PREPENDS its entries under the matching heading instead of dragging in a duplicate heading set. The single auxiliary section `### Known gaps (documented, NOT fixed)` is a deliberate exception: it lists what is NOT fixed and must never fold into `### Fixed`. - Entry leads open with the user-visible sentence; internal IDs (step numbers, review-ticket labels) trail the lead, never replace it. - Within a type, entries are newest-first. Dated release sections below are verbatim history: corrections are disclosed in a newer entry (or an italic note like the `SV-*` / missing-`1.0.0` ones in place), never by rewriting content. - Each dated release section opens with an **`### Upgrade notes`** subsection: the breaking changes and the operator actions a deploy must take for that release (migrations needing a rescan or a manual step, env vars that became required, defaults whose meaning changed), or a single line `None.` when the release is drop-in. The convention is FORWARD-ONLY: notes are written at release-cut time and older dated sections stay verbatim history — pre-convention releases keep their inline bold callouts (the `MIGRATION ANSWER` shape, e.g. the S152/S156/S331 entries) instead of being retro-moved. Operators upgrading across several releases read the notes of every release crossed. - Sections whose heading is an internal track-milestone label (`## [SV-]`) are not SemVer releases; they sit in date position and are annotated, not relabeled. ## [Unreleased] ### Added - **The WS query-carrier gate gained its stored-home admin control: `auth.ws_query_token` is now a live lever, not a promise.** For an admin to retire (or temporarily restore) the legacy `?token=` handshake carrier per install without touching host env and without a restart, set the shared-schema key `auth.ws_query_token` to `allow` or `deny` in Settings. The gate answer now resolves through one SSOT resolver (`src/Server/WebSocket/QueryTokenGatePolicy.php`) with fixed precedence: explicit `PHLIX_WS_QUERY_TOKEN` env (operator override in BOTH directions — env `deny` outranks a row `allow`; the escape-hatch `allow` still restores the PHASE A behavior byte-ident) above the admin row above the shipped default, which stays the CP-024 PHASE B `deny`. The row is read live per decision — a flip lands on the next handshake — and a broken or unusable row (anything outside the `allow`/`deny` enum) or an unreachable store normalizes to deny with one bounded redacted warning (type name only, never the stored bytes), so a bad admin value can only restore the shipped posture, never widen it; the env tier's set-but-garbage fails-open vocabulary is UNMOVED. The three sites that each carried their own `deny` fallback (config surface, server constructor, middleware constructor parameter) now defer to the resolver, and an unset/empty env passes through as silence (null) instead of synthesizing `'deny'` — every row-absent handshake denies byte-ident to before, same pre-101 reject path, zero new wire codes. Bearer and admitted-cookie handshakes never consult the row and stay byte-ident under every setting (CP-006 precedence law UNMOVED). The key lands in the admin API with the next phlix-shared schema re-vendor; the resolver honors the row through the store whatever the write path. Law table: `docs/dev/WEBSOCKET_AUTH_CARRIERS.md` ([off-kit owner ruling 2026-11-10]). - **Operators can opt the legacy `?token=` WebSocket carrier into retirement.** `PHLIX_WS_QUERY_TOKEN=allow|deny` (config `websocket.query_token`, default `allow`, read at boot) gates the `:8097` handshake's legacy query carrier: `allow` keeps today's behavior and logs one redacted telemetry line per query handshake (the census that times the default flip); `deny` additionally refuses query-carrier handshakes pre-upgrade with the existing reject idiom (pool reap + TCP close — no HTTP envelope exists before the 101, so no new wire vocabulary). Bearer-subprotocol and admitted-cookie handshakes are byte-ident under every setting. PHASE A of the COOKIE-DESIGN.md §4.1 retirement plan; the §2.3 carrier-precedence law (CP-006) is unchanged (CP-007). The stored-home settings key `auth.ws_query_token` shipped in phlix-shared for this program already; its live read-through joins this env lever in a future server leg (same staged shape as `auth.cookie.insecure`), and PHASE B (CP-024) flips this default to deny. - **The portal honours a configured WebSocket origin: `PHLIX_WS_URL` and `PHLIX_WS_PATH`.** A deployment that serves the SPA from one host but terminates the watch-party socket on another (split haproxy front, TLS-offload box) had no way to say so — the embedded `__PHLIX__` payload carried no socket coordinates, so the bundled client could only derive them from `window.origin`. `SharedUiController` now injects both keys with the hub's `HUB_WS_URL`/`HUB_WS_PATH` semantics mirrored exactly (same host grammar, same path grammar, same port-range rule, fail-soft to null on junk — not a 500); the CSP `connect-src` gains the configured origin as a validated extra source only when it names a different host than the request's, so a same-host config adds no duplicate. The vendored `@phlix/ui` bundle already consumes these keys from `__PHLIX__` — no `web-ui/` source change and no `/app` rebuild was needed (the repo's zero hand-rolled socket dials are a measured fact, recorded in the seam's docblock). 36 new tests (`SharedUiSocketSeamTest`) including a shell-level injection check. (LW-077-a server half; the phlix-ui `socketHint.ts` producer list flips with the next pin-wave.) - **`JwtHandler::validateClaims()` returns the shared `Phlix\Shared\Auth\JwtClaims` DTO.** The shared class's docblock promises that "the server's JWT validation path deserializes into `JwtClaims`"; this is that half, shipped as a typed **view beside** `validateToken()` rather than a flip of its `?array` contract. Two measured reasons the in-place flip cannot ship today: the `profile_id` custom claim rides the array return into `AuthManager::refresh()` (the DTO drops unknown claims, so flipping would silently sever profile scoping), and the array path pins a digit-string-`exp` tolerance the DTO's `requireInt` would newly reject — an acceptance-set change disguised as a type change. The view is verification-first: `validateToken()` runs (signature, `iss`, lifetime) before `JwtClaims::fromPayload()`, a payload the verifier rejected yields null, and a token signed with a rogue secret can never produce claims. The default-`aud` shim inside `fromPayload` and the mint-side `aud` stamping that retires it are documented as the migration track (8 new tests, `JwtClaimsServerHalfTest`). (LW-079-a server half) - **`user:create` can read the password from a file or a pipe (`--password-file=`).** An explicit `--password=` on the command line lands in shell history and is readable through any local `/proc//cmdline` reader until the process exits. `--password-file=PATH` — or `--password-file=-` to read stdin, with the trailing newline trimmed — keeps the secret off argv; a non-empty `--password=` still wins, and an unreadable source refuses with `Command::INVALID` before any write. The post-install banner and the README admin-creation recipes now teach the safe form. [SH-032 / server__seceng-docs#6, w20L2 2026-11-10] - **Browser sign-ins now receive the HttpOnly session cookies on the JSON API too.** A same-origin browser SPA posting JSON to `POST /api/v1/auth/login` or `POST /api/v1/auth/register` gets the byte-identical JSON token envelope AND the `phlix_session`/`phlix_refresh` cookie pair (rotated on every `POST /api/v1/auth/refresh`), so the hosted web UI can keep its session in an HttpOnly cookie instead of JavaScript-readable storage. Device-class clients (any `X-Phlix-Device-Type` header, app://-style webview origins, foreign origins, or no browser signal at all — curl and every legacy native client) never receive a `Set-Cookie` header, and their JSON bodies are byte-for-byte unchanged. Cookie issuance follows the scheme law: https or localhost only; an http-LAN deployment keeps today's body-token posture unless the operator opts in (`PHLIX_COOKIE_INSECURE=1` or the stored `auth.cookie.insecure` twin), and `auth.cookie.enabled=false` suppresses every auth-cookie path server-wide as the config-level rollback lever. [COOKIE-DESIGN §2.1/§2.2/§2.4, owner ruling CD-3a / CP-003, w28L1 2026-11-09] - **The OpenAPI spec advertises the session cookie as an authentication carrier.** A `sessionCookie` securityScheme (`apiKey` in cookie, name `phlix_session`) is now listed as an alternative to `bearerAuth` on all 316 bearer-authenticated operations; the credential-establishing public operations (`login`/`register`/`refresh`/...) keep `security: []`. Purely additive spec documentation — no wire behavior, no body envelope, and no request that used to be accepted or rejected differently changes. [COOKIE-DESIGN §3 / CP-002, w28L1 2026-11-09] ### Security - **Signing out of one device now signs out exactly that device — and a stolen refresh token revokes its family, not the account.** Every login ceremony on this server now mints its token pair inside a session family: a per-login family id travels in the JWT `sid` claim and lives in `auth_token_sessions` alongside its rotated-refresh lineage in `auth_refresh_grants` (migration `127_auth_token_sessions.sql`). Two endpoints expose the capability: `GET /api/v1/auth/sessions` lists the account's live families with the caller's own flagged current, and `DELETE /api/v1/auth/sessions/{id}` ends one — ownership is enforced by the store's WHERE, foreign and unknown ids answer the same 404 (no probe oracle), and every other device keeps its session. The access path reads family liveness live on every request, so a revoked device dies immediately rather than at its token's TTL. On the refresh path the presented grant is consumed at rotation; replaying an already-rotated refresh token outside the grace window is the theft signal: that family is revoked and the event audited as `refresh_token_reuse`, while a replay inside the window — the benign multi-tab race CP-005 exists for — rotates on inside the family unchanged. The account-wide semantics are kept exactly as shipped: logout, password change and admin revocation still bump the `tokens_not_valid_after` watermark — now mirrored by revoking every family at the same choke point — rejection envelopes stay byte-identical with zero new wire codes, and tokens minted before migration 127 (no claim to consult) are judged by the watermark path alone, unchanged. (CP-029 / D-1a; the capability COOKIE-DESIGN §5 deferred to this phase, owner-ratified 2026-11-10.) - **Forward correction to the CP-008 test-suites' mutation claims: the CSRF belt's parse-false arms now convict by execution (tests-only).** The w31 L1 commit body stated, verbatim: "11 plant/strip mutation arms each uniquely convicted", and the same body credited the new dispatch matrix with "both parse-false convictions". An independent adversarial execution (re-verified by re-execution at this tip) proved the fixture premise behind those legs false to the runtime: `parse_url('http://[')` returns an array on PHP 8.3.6 (scheme + host `[`), so the unparseable-Origin/Referer matrix rows refused through the host-mismatch leg instead and force-allow plants on either parse-false arm left all three CP-008 suites green — an unconvicted claim riding the register and commit prose. The roster count rode the same correction: twelve plant/strip arms shipped (M1, M1b, M2–M11), not eleven. Cure, tests-only, zero src change, zero wire change: both fixture values are inputs MEASURED parse-false on this runtime (invalid non-numeric port; ambiguous multi-colon authority), and a new premise-falsification pin asserts `parse_url() === false` for the shipped fixtures on every run — the estate law this verdict instantiated: a fixture that claims to be unparseable must prove it by execution, or the suite reddens at the pin, never silently in some later mutation transcript. With the cure applied, each original force-allow plant reddens exactly two named legs (its swapped matrix row plus the shared-envelope byte-equality law) while the other two CP-008 suites stay green. [CP-008 test-law correction, w31L1-R2; COOKIE-DESIGN §7, REVIEW-w31L1server.md Major + Minor; FIX-server-w31L1-round2 register 2026-11-10] - **Forward correction to the belt entry below: "belt position-purity" sold a non-property — and the correction wave brought the rotation-grace memo's CAP limb its first true conviction.** The shipped belt text listed, verbatim: "13 new legs: belt position-purity, inert-when-absent, per-token provider (no broadened match), both-absent non-rescue, and two dispatch-gate pins — cookie POST refused / bearer POST immunised — under the belt." An independent adversarial execution (re-verified by re-execution here) proved the position-purity leg unconvicting as described: the state-changing origin ladder's arms all refuse on their own triggers and never rescue, so relocating the Sec-Fetch-Site belt anywhere still ahead of the accept tail — AFTER the Referer arm included — is an equivalent mutant that turned ZERO legs red, while the "5 red" conviction list recorded for that row reproduces exactly under the belt-STRIP placement (the same named 5). The belt's behaviorally observable law is REACHABILITY, not position; belt-first stays the CODE-ORDER law — the verbatim hub mirror, unchanged, no src edit. The companion false claim lived in the round's mutation matrix, not in src: the memo-cap leg shipped as cap-minus-one (63 seeds plus one store land on 64 capped or not), so a full strip of the FIFO prune measured GREEN across the touched-suite set. The behavior was always correct — the tests could not see this limb. Three new drift-guard legs join the panel (tests-only; zero src change, zero wire change): at-the-cap FIFO eviction (strip, PHP_INT_MAX-bound, over-evict and LIFO plants each redden exactly that leg by name), the grace window's ten-second MAGNITUDE (re-tuning the constant to a day measured GREEN everywhere until this pin existed), and the server's own empty-Host discrimination leg (making an empty server host wildcard-match measured GREEN server-side — the hub program's convicted empty-Host shape finally mirrored on this side). The belt leg's docblock was forward-corrected in place to state the true convicting shape. [CP-009/CP-005 corrections + w29L1-R2 pins; COOKIE-DESIGN §2.5/§5, FIX-server-w29L1-round2 register 2026-11-10] - **The browser WebSocket handshake can now ride the session cookie, and a racing refresh rotation converges instead of diverging.** - *WS cookie carrier.* The `:8097` handshake gains a third credential carrier with fixed precedence — bearer subprotocol first, then the `phlix_session` cookie, then the legacy query token. The cookie carrier is admitted only when the state-changing request-origin law (the same `CookiePolicy` single source as the HTTP belt, Sec-Fetch-Site included) allows it, and a browser-shaped upgrade presenting a jar with NO Origin or Referer fails CLOSED: ambient-credential smuggling is exactly what the design's "mirror of validateCsrfOrigin" reading forbids. A refused cookie is not a credential: it never blocks an explicitly supplied query token and never participates in the bearer-vs-query mismatch law (a stale jar is not a contradiction, it is noise). The `Sec-WebSocket-Protocol: bearer` echo stays the law of the carrier actually accepted — a cookie-authenticated handshake answers no bearer marker — and relay `:8804` remains bearer-subprotocol-only. No shipped estate client's behavior moves: the ui's handshake helper offers no subprotocol at all when it has no bearer token, which is precisely the jar mode this carrier serves. 13 new legs (7 pure-resolver, 6 wired-hook through the real upgrade request path); 4 convicted red-first at the parent, the 9 refusal-legs green there by design — non-vacuity disclosed, not assumed. (COOKIE-DESIGN §2.3; CP-006) - *Refresh rotation grace.* `AuthManager::refreshToken()` now remembers the pair it just minted for ten seconds, keyed by the refresh token's `jti` (never the token string). Two tabs racing one refresh token converge on the identical response — same access token, same rotated refresh token, same cookie re-attachment — instead of each minting a pair whose loser is silently orphaned. The memo is consulted only AFTER every authorization gate, so a watermark bump inside the window still refuses (pinned); the store is capped and expired entries are swept on write, and eviction degrades benignly to a fresh mint. Rotation never revoked anything before and does not now, so a cross-worker double rotation leaves both pairs valid — the per-worker memo converges responses, not authority. Accepted state, stated plainly: with no revocation store, a stolen pre-rotation refresh token is indistinguishable from a racing tab until the watermark bump; grace-window idempotence is the mitigation the design chose, not theft detection. 5 new legs; two errors + two failures convicted red-first at the parent, the distinct-presentation identity pin green there by construction. (COOKIE-DESIGN §5; CP-005) - **A logged-out hosted session's cookie jar now dies with the logout, and the CSRF origin law gained its Sec-Fetch-Site belt — closing the cross-host drift row.** - *JSON logout.* `POST /api/v1/auth/logout` — the route every browser logout of the hosted SPA has been posting since the shipped `@phlix/ui` cookie-custody leg — now EXISTS (it 404'd: only the form route `/auth/logout` was registered), so the `phlix_session`/`phlix_refresh` jar no longer survives a browser logout until the JWT TTL. `logoutJson` answers 204 with ZERO body bytes on every accepted arm (the jar clear rides additive `Set-Cookie`; the shipped ui parses nothing back), clears BOTH names unconditionally — issuance-switch-independent, per the re-introduction law that clearing is hygiene, not issuance — and keeps the server's real teardown on the attached arm (the M-1 `tokens_not_valid_after` watermark bump + `endAllUserSessions`); the anonymous arm never touches the watermark. Both halves pinned, plus the form-path 302 non-regression leg. Semantics twin of the hub's ratified JSON logout (watermark revocation stands in for the hub's jti store — semantics, not literals). CSRF on this arm is structural: cookie-carried POSTs pass the Origin law (now belt-inclusive) BEFORE the handler runs — a foreign-origin cookie logout 403s with no revocation strike and no `Set-Cookie`; bearer-lineage logouts are never gated. Router, wire- manifest guard, and the openapi operation all rotated in the same commit (spec prose from real behavior only; the spec-currency count re-measured from its own red). New suite `CookieLogoutJsonTest` (5 legs — four red- first against the parent where the method did not exist). (CP-004; COOKIE-DESIGN §2.2) - *Sec-Fetch-Site belt.* The server twin of the hub's shipped belt — the hub commit that added it literally recorded that the server had not grown one (one-directionally stricter drift). The state-changing request-origin law moved to `CookiePolicy::stateChangingRequestOriginAllowed` (SSOT, mirroring the hub's placement): the belt acts FIRST — `cross-site` (trimmed, case-folded) is refused even when Origin and Referer fully match — while every other token (`same-origin`, `none`, `cross-origin`, junk, empty) leaves the verdict to the existing arms and header ABSENCE stays inert for native clients. The mirrored arms are preserved verbatim: both-absent and literal-`'null'` refusals, fail-closed Host/parse arms, raw host equality with both-or-neither ports, and the hub's psalm-cured port reading (no redundant int casts). `RequestAuthenticator::validateSameSiteOrigin` now delegates, and the WS cookie carrier of the same wave reads through the same single source. 13 new legs: belt position-purity, inert-when-absent, per-token provider (no broadened match), both-absent non-rescue, and two dispatch-gate pins — cookie POST refused / bearer POST immunised — under the belt. (CP-009; COOKIE-DESIGN §2.5) - *Same-commit guard rotations.* A registered route is a line-number and inventory event: the README route table's ten cited registration lines, the composed-route inventory artifact (route, total, and POST histogram), the wire-guard manifest tuple, the OpenAPI currency count pin, and the declared-property census denominators (file count and the Request-rooted read/write sites the new suites and handler touch) were ALL re-anchored from this commit's own measured reds — no pin moved on trust, each rotation rode a named assertion failure. - **Nine admin controllers no longer echo raw exception text onto the wire — for the backup family that text was process output, re-thrown `PDOException` statements, and absolute filesystem paths (review server__copywriter #1, SH-003, M-SERVER-05 idiom).** `BackupController` (all seven 500 arms), `MaintenanceController` (the shared `failure()` helper behind four arms), `AdminWebhooksController` (the shared `errorResponse()` helper), `AdminTranscodingController` (the accelerator-probe arm), `AdminUpdatesController` (the 503 dispatch-failure arm), `FsBrowseController`, `AdminRestartController` (the defensive `restart()` arm), `LdapAdminController` (the connection-test arm) and `PluginAdminController` (the install arm) now carry a fixed operator-facing message per controller and log the exception class, message and origin to the HTTP log channel instead. Envelope shapes, per-arm `error` labels, statuses and the wire code set are byte-identical — the S273 survival token still rides `AdminUpdatesController`'s `error` field, and `plugin.install.failed` keeps its `code` and structured `fields`. The `MaintenanceController` helper's pre-SH-003 docblock that deliberately surfaced curated task copy is rewritten to the new law (its one pin, `MaintenanceControllerTest`, flipped in the same commit); the two `LdapConnection::testConnection()` catch arms stay as designed — they echo only admin-submitted connection parameters. A new law test covers both the behaviourally reachable arms and, structurally, every cured class. [SH-003 / server__copywriter#1, w22L2 2026-11-10] - **Insecure auth cookies under `PHLIX_ENV=production` now speak up.** The plain-HTTP development opt-out (`PHLIX_COOKIE_INSECURE=1` or its stored `auth.cookie.insecure` twin) drops the `Secure` attribute from auth cookies — a dev-only posture that until now was steered by prose alone: the README said "never in production" and nothing echoed it. An active opt-out during a production-tier run now raises exactly one `E_USER_WARNING` per process naming the sniffing risk and the remedy (terminate TLS, turn the opt-in off) — the same warn-once latch idiom as the boot-time DB-credential guard in `config/database.php`, including the `$GLOBALS` latch as a documented test seam. It stays a warning, not a lockout: the opt-out still works where it is deliberate. The env census row records the actual consumers (`CookiePolicy` SSOT plus the OAuth2 plugin's direct env readers) and the warn behavior. [SH-033 / server__seceng-docs#7, w20L2 2026-11-10] - **A hub TLS anchor can no longer be silently absent.** When the operator explicitly configures a CA anchor — `PHLIX_HUB_TLS_CAFILE` (or the `tlsCafile` constructor argument) for hub REST calls, `PHLIX_RELAY_TLS_CAFILE` (or `config/relay.php` `relay_tls_cafile`) for the hub relay dial — an anchor that is not a readable file now REFUSES loudly instead of being quietly dropped: `HttpClient::tlsContext()` throws a `RuntimeException` naming the path, which source set it, and the exits (the refusal is deterministic whether the file is missing or present-but-unreadable), and `RelayConsumer::buildSslContext()` throws at dial time — surfacing as the connect path's existing ERROR log plus a scheduled reconnect rather than a crash. Before this cure the explicit-but-unreadable anchor fell back to the system trust store with no word at all: pinning silently became "whatever the box trusts". Unset anchors keep the prior system-store behavior verbatim (§5.1 tolerance), and `PHLIX_*_TLS_VERIFY=0` still opts out entirely without consulting the cafile. The refusal texts and the README env rows now also carry the anchor law: an anchor wants CA/root certificates, NEVER the hub's leaf/fullchain — leaves rotate roughly every 60 days and a pinned leaf expires the trust in silence. [wildcard-cert rotation audit §3.2/§5.2 server twin, 2026-11-10] - **The vendored `phlix-hub` helm chart can no longer deploy with its placeholder secrets.** `values.example.yaml` ships `database_password: "CHANGE_ME"` and `secret_key: "openssl rand -hex 64"` — the COMMAND as the VALUE — and `templates/secret.yaml` wrapped both in bare `required()`, which only proves non-emptiness: `helm install -f values.example.yaml` therefore succeeded verbatim, booting a hub with a JWT signing key and DB password that are public to anyone who can read this repository. The server chart learned exactly this as M29; the hub chart now carries the same render-time refusal (`phlix-hub.rejectKnownPlaceholder`, its dirty list pinned byte-identical to the server helper's by `HubChartPlaceholderSecretGuardTest`) on both keys, and the example keeps the bait strings ON PURPOSE so a copy-paste install fails loudly. `k8s/README.md` is corrected in the same pass: the install examples no longer pass credentials through `--set` argv (shell history / `ps` leak) and no longer advertise the hub's "own required … values" as the safeguard; a new Secret hygiene section documents the refusal and the `--values` file (mode 0600) idiom. [server review SH-027 / seceng major 1, w18L1 2026-11-09] - **A debug-armed container can no longer answer the public Xdebug trigger default.** `docker/php.ini` shipped `xdebug.trigger_value = "${XDEBUG_TRIGGER:-devdebug}"` and `docker/examples/.env.example` claimed "leaving it unset changes nothing" — the claim WAS the exposure: `devdebug` is committed here, so anyone who reads it can start debug sessions on any container that arms `XDEBUG_MODE` without overriding the trigger (trace files piling into `/tmp/xdebug`, plus disclosure of which requests are traced). Deleting the default is ruled out by Xdebug semantics: with `start_with_request = trigger`, an EMPTY trigger value means ANY value triggers. `docker-entrypoint.sh` now owns the per-deploy randomization the host installer (`scripts/install.sh`) already does, per boot: when debugging is armed it mints a 64-hex trigger and prints it once with the `PHLIX-XDEBUG-TRIGGER` marker (operators read it from `docker logs`; it rotates on restart), refuses to start at all if the operator explicitly sets the public literal (`PHLIX-XDEBUG-PUBLIC-TRIGGER` banner), and honors any private value verbatim. Mode off — the shipped default — boots byte-identically to before, with no trigger handling. The ini fallback is now a self-describing sentinel instead of a shared public word. [server review SH-031 / seceng minor 5, w18L1 2026-11-09] - **Login throttling gained a second, per-account failure window.** `migrations/126_login_rate_limit_account.sql` creates `login_rate_limit_account` — the per-identifier sibling of `login_rate_limit` (074): same shape (attempts / reset_at / bounded sweep), keyed by the normalised submitted identifier instead of the client IP. Read with the SH-017 Security entry below. (server review SH-017, w17L3) - **Every transcode `start`/`status` variant now advertises its mini-master index URL, signed.** Each playable-variant entry gains `playlist_url` — the rung's `media_v{id}_master.m3u8` index (the file `writeVodPlaylists()` has shipped beside every bare rung since the 2026-11-06 audio cure), relative and signed exactly like `url` and `master_url`. Pinned-quality clients previously had to DERIVE that name by editing the signed master URL; one `@phlix/ui` patch appended it after the `?exp=…&sig=…` query — the filename landed inside the signature VALUE, the request path lost its extension, and every pinned-quality load 404'd (owner incident 2026-11-06 "can't play"). With the field shipped the derivation is unnecessary — clients use the value VERBATIM. Bare `url` is untouched and stays the older-server/muxed-audio fallback; `playlist_url` is the only key added to the variant shape. Pinned by `TranscodeManagerTest::testGetJobVariantsCarriesPerRungMiniMasterPlaylistUrl` (manager shape, both keys distinct) and `TranscodeControllerTest::testStartSignsThePerRungMiniMasterPlaylistUrlVerbatim` / `…testStatusSignsThePerRungMiniMasterPlaylistUrlVerbatim` (wire signing + the exact `…_master.m3u8?` query boundary). The signature canonicalises to the `/hls/{job}` prefix (SignedUrl::canonicalResource), so minting per URL costs one HMAC and one token still authorises the whole job dir. No new wire codes; no migrations. [playback incident 2026-11-06 / URL-contract law] ### Security - **A password-guesser can no longer spread one account across many IPs to stay under the throttle.** `AuthManager` login throttling was keyed purely by client IP — `DbLoginRateLimitStore` counted failures per address, so rotating source IPs (botnets, or the simple case of a shared NAT pool on the attacker's side) reset the budget for a FIXED target account every time. Every credential failure now also charges a per-account window keyed by the normalised submitted identifier (trim + casefold — `ALICE`, ` alice` and `alice` share one budget): an unknown identifier is charged exactly like a wrong password (anti-enumeration symmetry — both paths keep burning the real verify-time and return the byte-identical generic envelope; no new wire codes, the 429 still maps centrally through `RateLimitException`), provider login and the OPDS Basic path obey the same law. The IP window is evaluated FIRST and unchanged — the existing IP ladder (5/900s, `login_rate_limit` 074) keeps its exact semantics, pinned by a preservation test; the account window is an additional trip (same 5/900 shape, new table 126). Accepted tradeoff, stated on purpose: an attacker who knows one username can lock that account for a ≤15-minute window — the same self-DoS posture the IP law already took. The unmigrated fallback in-memory store mirrors both windows under `acct:`-prefixed keys. Conviction: 5 of the 7 new/rewritten throttle tests RED at parent (e.g. five failures against five IPs then a fresh IP still locked the account only post-cure); green with the cure. Tests: `tests/Unit/Auth/AuthManagerAccountRateLimitTest.php` (6, fallback-store venue incl the ladder-preservation law), rewritten `AuthManagerOpdsThrottleTest` per-account + IP-hopping cases, 4 store SQL-shape/precedence pins. (server review SH-017, w17L3) - **The DVB-T signal probe no longer builds a shell string around configured paths, and its output file is no longer guessable.** `DvbtSignalEngine` interpolated `$ffmpegPath`/`$zapPath` (and the adapter/frontend numbers) straight into the `dvbv5-zap … 2>&1` command it hands to `proc_open`, writing a PREDICTABLE `/tmp/dvbt_stream__.ts` — a local symlink-preemption vector (another uid's file or a planted symlink under /tmp), plus the quoting gap itself: the sibling builder escaped one path while this one escaped none. The live path is now shell-free: `zapArgv()` returns an argv LIST (configured binary first, verbatim — a `; touch /pwn` path stays one opaque element, mutation-proven), `startStreaming()` `proc_open`s the list form, and the capture file comes from `tempnam()` — unpredictable name, 0600, ownership-checked, released by `tune()`'s probe, `stopStreaming()` and the failed-launch path alike. `tune()`'s display string survives for the debug log, fully `escapeshellarg`-quoted. Tests: 4 new (`DvbtSignalEngineTest`, 7 total) incl. a real-`proc_open` launch/teardown case and a predictable-name extinction scan; parent mutation 1 error + 3 red. (server review SH-018, w17L3) - **A fresh hub enrollment keypair is now born OUTSIDE the git tree, and no `.pem` may ever be tracked here again.** The default identity key path was hardcoded to `config/hub-server-key.pem` — a directory this repo VERSIONS, so any operator who paired in place and then ran `git status`/`git add -A` risked committing the private Ed25519 enrollment identity (no key was ever shipped; the defect was the birthplace). `Ed25519KeyManager::resolveKeyPath()` is now the single resolver, in precedence order: explicit configured path > `PHLIX_HUB_KEY_PATH` env > **legacy `config/hub-server-key.pem` if it already exists** (existing installs keep their identity — zero breakage, the compat probe) > `var/hub-server-key.pem` (git-ignored, alongside the other runtime state). Wired through every consumer: `config/hub.php`, `HubServicesProvider`, the JWKS branch in `Application`, the `HealthController`/`AdminHubController` manual fallbacks, `scripts/pair-with-hub.php`; `.gitignore` gained `*.pem` + `var/hub-server-key.pem`, pinned estate-wide by a `git ls-files '*.pem'` extinction test (repo-wide scan, 6 resolver cases). Installer README step-4e prose and the root README JWKS paragraph carry the truth. (server review SH-019, w17L3) - **The newsletter can no longer be made to forge email headers by a self-registered username.** `NewsletterSender::sendNewsletter()` interpolated `users.username` raw into the `To:` header — and registration bounds the name only by trim + length 3-50 (its own docblock states no charset whitelist is applied), so a username containing CRLF imploded extra header lines straight into the `mail()` header block: the classic Bcc-forgery / spam-amplification surface, with no vendor stripping on this path (unlike the HTTP Response encoder). Every header slot now composes in one place: display names (recipient and config `from_name`) are control-byte-stripped, `"`/`\`-escaped and wrapped as RFC 5322 quoted-strings — so commas, semicolons and angles stay cosmetic instead of forging addressees — the config address slot sheds control bytes and angle/backslash characters, the subject is scrubbed of control bytes (PHP `mail()` puts it in the header block itself), and the envelope recipient stays the DB email, never a header value. Twelve wire-shape pins in `tests/Unit/Admin/NewsletterEmailCompositionTest.php`, mutation-proven: strip removed → 2 red; plain payload blanked → its decode pin red. (server review SH-011) - **A regression pin now guards the retired `PHLIX_SECRET_KEY` env line in the full installer's generated env file.** When the systemd carrier retired that dead key (a docker-entrypoint input the installer wrote but nothing on the systemd carrier reads), the absence law was pinned only against `install/systemd.sh` — the `scripts/install.sh` env heredoc could re-grow a `PHLIX_SECRET_KEY=` line and stay green (mutation probe: exactly that line re-added, whole suite zero red). The new `SystemdInstallerEnvTest::testFullInstallerEnvHeredocNeverCarriesTheRetiredSecretKey` parses the real installer's heredoc (with parser teeth) and fails the moment the retired key reappears; proven red-first against the planted line and green against the shipped script. - **The documented fresh-install one-liner now fetches the installer from a pinned release tag instead of `master`.** `curl -fsSL https://raw.githubusercontent.com/detain/phlix-server/master/scripts/install.sh | sudo bash` executed whatever `master` held at fetch time, as root, with no pin, checksum, or review step anywhere in the docs — a supply-chain vector in the primary install story. The README headline (and the "alongside phlix-hub" example's server leg) now pins the current release tag (`v1.2.3`), tells the operator to bump it, and adds a download-and-inspect alternative. The `--update` one-liner deliberately stays on `master`: it is the exact string the runtime-mirrored `updates.update_command` advertises for pulling the newest release (review server__seceng-docs #9). - **The HAProxy and Caddy templates now warn that `ws://` puts the handshake JWT on the wire in clear.** Both presented plaintext `ws://` as the default or simplest way to front the `:8097` WebSocket worker with no credential caveat, even though the repo's own README and systemd notes already describe that leg as plaintext, JWT-bearing traffic. Each site gained a one-line caveat pointing at the README exposure note and `docs/dev/WEBSOCKET_AUTH_CARRIERS.md` (review server__seceng-docs #4). - **A failed Schedules Direct token fetch no longer writes the account username into the error log.** The `username` context key on the token-failure log line is replaced by a truncated, non-reversible SHA-256 fingerprint, so operators can still correlate repeated failures for one account without the admin-configured identifier landing in log files (review server__secaudit #4). Red-pinned by `SdApiClientTest::test_fetch_token_failure_log_never_carries_the_raw_username`. - **The plugin-update laundering record is corrected in a newer entry (its own discipline), and the cure now has test teeth.** The landed entry "INFO tail: a dead WS auth superglobal rail is gone, and the plugin-update rethrow stopped laundering transport prose into client JSON" claimed the laundered prose "reached admin client bodies through every downstream echo (the checkUpdates row `error`, the update leg's 404 copy)". Only the first half is true: `fetchLatestVersion()`'s rethrow is read by `checkUpdates()` alone; the update refusal's 404 copy is curated by `update()` itself and never sees that message (srvtail-R1 Info — the landed narrative over-scoped; the shipped cure was and is correct). Srvtail-R1's Major was the absent teeth: reverting the fixed-copy+log-only block kept every plugin-family test green. Three pins now red exactly under that revert — `PluginUpdateServiceTest::test_transport_failure_row_carries_the_curated_ copy_and_never_the_exception_prose` (byte-identical curated row, planted prose absent), `test_transport_failure_routes_the_real_prose_to_the_plugins_ log_only` (estate temp-stream captured-logger seam: class + message + repo on the PLUGINS channel), and `PluginCatalogControllerTest:: test_updates_leg_surfaces_the_curated_row_never_transport_prose` (same law at the HTTP body). A fourth, `test_update_refusal_answers_the_no_source_ envelope_with_curated_copy`, answers the 404 `plugin.update.no_source` envelope byte-identical with a prose-throwing fetcher armed — pinning the honest scoping above at the wire. Behavior unchanged; zero new wire codes. [review server__secaudit / srvtail-R1 Major + Info] - **Append-only restoration record (procedural note; no code, no sentence rewritten).** The entry below printed "Fifteen raw-`Throwable` catch arms" when commit 194b278f landed AND pushed it; commit f758546c then changed that word to "Fourteen" by editing the landed line IN PLACE instead of appending. The correction is TRUE (twelve `AdminHubController` arms + the DLNA persist arm + the credential-test catch-all = fourteen) and was self-disclosed in both the commit message and the e2w register, so nothing shipped reads falsely — but the verbatim record was rewritten, and this file's own discipline reserves corrections for NEWER entries naming the prior claim. This note restores that record: 194b278f's landed word was "Fifteen"; "Fourteen" is the truth; the line stays exactly as corrected from here on. The compliant pattern is 10a82683 in this same file — a pure insertion correcting 947f7de8's CHANGELOG over-claim, invoked explicitly under append-only discipline. [review server__secaudit, e2w-R1 Major] - **Admin and plugin endpoints stopped echoing exception text to clients.** Fourteen raw-`Throwable` catch arms answered the (otherwise byte-identical) JSON envelope with `$e->getMessage()` embedded: twelve `AdminHubController` remote-access arms — shipping absolute key-file paths, upstream hub URLs and echoed rejected input to any authenticated admin client; the `AdminDlnaServerController` persist arm — whose store failure is typically the rethrown `PDOException` carrying the full SQL statement; and `PluginAdminController::testCredentials()`'s catch-all — relaying third-party plugin exception prose, redacted only for the just-submitted secrets. Each now answers a fixed curated copy in the unchanged envelope (same status codes, same key shapes, zero new wire codes); exception class + message go to the server-side HTTP log only, and on the credential-test path the message passes through the submitted-secrets scrubber on its way there. Typed domain catches (signup-disabled, plugin-not-found, invalid-argument, …) stay as designed, per the E2 triage law. The register named nine admin-hub arms; the file was byte-unchanged since the review so the true count was twelve — all twelve converted. Red-first pins: three real drives + a one-exit source tripwire (admin hub, +4 tests), marker-in/absent captured-logger pins (DLNA +1, credential test +1). [review server__secaudit, INFO-SERVER-51] ### Changed - **The `:8097` legacy `?token=` WebSocket query carrier is refused by default.** The CP-007 gate shipped as "`PHLIX_WS_QUERY_TOKEN=allow|deny` (config `websocket.query_token`, default `allow`, read at boot)"; CP-024 PHASE B flips the shipped default to `deny`, exactly as that entry forecast the §4.1 plan would. Unset or empty env now refuses query-carrier handshakes pre-upgrade with the existing reject idiom (pool reap + TCP close — no envelope exists before the 101, zero new wire codes; the frozen error-code registry is untouched). Hosts running straggler clients restore the previous behavior with an explicit `PHLIX_WS_QUERY_TOKEN=allow`, byte- identical to Phase-A acceptance including its redacted telemetry line. The parser vocabulary is unchanged by the flip: `deny` remains the only denying value and unrecognized values still fail open to `allow` (opt-in-tightening law). Bearer-subprotocol and admitted-cookie handshakes stay byte-ident under every gate setting — absent, `allow` or `deny` — the CP-006 precedence law is UNMOVED. The flip is gated on an estate producer census (2026-11-10) that found zero live `?token=` WS-handshake producers: phlix-ui, hub, tizen, windows, console and roku dial bearer/cookie carriers, and mobile — the last query producer — flipped its direct lane to the bearer subprotocol at phlix-mobile-client `bdbe1e1`. Measured golden rotation: eight unit expectations and one real-socket integration expectation that pinned the gate-absent leg to the Phase-A accept shape reddened on the flip and were rotated to the new default law; two query-vehicle auth/rate-limit law legs gained explicit `allow` pins so the laws they exercise stay exercised. PHASE C (branch deletion) stays a later owner-timed move trailing fleet update. - **Composer dependency refresh: `detain/phlix-shared` `^0.53.0` → `^0.54.0` and the test-only `detain/phlix-tokens` archive pin `v0.2.2` → `v0.2.3`.** The shared v0.54.0 manifest parser narrowed the typed settings view; the server now reads every declared plugin setting through one canonical accessor (see the *Settings view* bullet), so the re-vendor lands with a small consumer adaptation instead of blind pass-through. - *Shared.* The `^0.54.0` constraint resolves through Packagist to the v0.54.0 tag peel `55f3e680` (annotated tag object `dcba451a`, ls-remote-verified; the release commit syncs `src/Version.php` to `0.54.0` and carries the `## [0.54.0]` CHANGELOG section). The lock rotation came from a real `composer update` scoped to the two packages with `--with-dependencies`; the diff stays inside the two package entries plus the content-hash, and a second scoped resolve is a no-op — lock idempotence proven. - *Tokens.* The v0.2.3 tag (object `358ca289`, peel `cfae43fd`, the then tip of phlix-tokens master) carries the accent-roster/contrast-guard/ reduced-motion train and its `dist/` rebuilds — none of which touch `src/css/colors.css`: the S228 guard's pinned bytes are byte-identical at the new peel (measured three ways — git-at-peel, raw-at-peel, delivered tarball), so `ColorsCssParityTest`'s sha256 golden rides UNMOVED (zero moved goldens this wave, measured not assumed) and only the guard's provenance constants and doc chain rotate to the new pin. - *Settings view.* Since v0.54.0, `Manifest::fromArray()` drops descriptors without a string `type` from the typed `Manifest::$settings` view, keeping them only in the untouched raw payload. The full parallel battery convicted exactly the server consumers that relied on the complete declared set — the masker's schema projection and `mixed`-fallback row, and the admin settings-update path (three tests). The server gained `SettingsMasker::declaredSettings()`: the canonical declared-settings map (raw-payload entries in manifest order, overlaid by the typed view), read by all six typed-view sites. That also closes two latent leaks the narrowing introduced — a typeless `secret: true` descriptor once again redacts its value in both the API mask and the admin-log redaction — a law now pinned by two new tests in `SettingsMaskerTest`. - *Dialect note.* The v0.54.0 cut predates `Phlix\Shared\Relay\SyncPlayDialect` (shared master `b67d920`, `@since 0.55.0`), so the server's dialect aliases keep riding a later shared release; the SSOT docblock in `Messages` drops its stale `0.53.0` era literal for a version-stable form. (LW-084 — [server w24 L2 2026-11-10]) - **The served web player carries the cookie-custody and single-flight refresh cures: `@phlix/ui` v0.99.25 re-pinned to v0.99.26, and the served `/app` bundle regenerated.** [ui pin wave 2026-11-10 — final consumer leg] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.25 → v0.99.26 (tag object `74c120bc`, peel `dc2e70fe`, ls-remote-verified against origin; ui release CI 4/4 green at the peel per the TW-ui-26 register). The dependency edges ride unchanged — contracts `#v0.5.7` / syncplay `#v0.1.7`, the blessed pair — so the lock delta is surgical: `package-lock.json` 5+/5- (root echo + the ui node triple: version/resolved/integrity, plus one disclosed upstream declaration line, the pinia peer widening `^3.0.0` → `^3.0.0 || ^4.0.0` carried by the ui package since its w21L4 commit `e2c45746`) and `package.json` 1+/1-, produced by a real `npm install` under npm 11.19.0 on the S253-pinned node 24.20.0 with `NPM_CONFIG_USERCONFIG=/dev/null` ("changed 1 package"); a second install is a byte-no-op — lock idempotence proven. The vendored `dist/` matches the ui register's consumer-target identities byte-exactly and equals the codeload tarball of the tag by recursive diff: `phlix-ui.js` `7cea63d4…`, `player.js` `a6428a08…`, `phlix-ui.umd.cjs` `e35db384…`, `player.umd.cjs` `01bc6133…`. Payload carried: the ui CP-014 cookie-custody wave (`5b8574ac`), the CP-016 401 single-flight refresh + logout leg (`8852c14a`), and the w21L4 pinia-peer/PageHint barrel item (`e2c45746`). - *Bundle.* `/app` SPA regenerated (S253 byte-exactness law): `npm run build` x2 under the pinned node, the two outputs byte-identical (`diff -r`), aggregate md5 `237b2c0178e82c1e8cbd92480698cb64` (family recipe `find | LC_ALL=C sort | xargs md5sum | md5sum`), corpus unchanged at 200 tracked files. The stamp chunk `index-CUn_z3ex.js` carries `0.99.26`; zero `0.99.25` residue under `public/assets/app`. The ui PlayerPage carrier rotation `Bp-fkb06` → `5onGq7ES` propagates as server chunk `PlayerPage-5onGq7ES-CiP9VnWo.js`; 61 hashed chunk pairs (js + map) renamed and `index.html` + `.vite/manifest.json` carry the new entry refs — churn honestly attributed to the two payload cure waves riding the pin, not a build anomaly (builds x2 agree). - *Currency.* The ui `src/i18n` diff `6e1ce2d8..dc2e70fe` is EMPTY by measurement, so no locale catalog rides this wave; the server's web-ui i18n tests are dynamic assertions (no static key-count pins) and stayed green unmodified. No PHP era-test holds a real-bundle literal at this tip (grep-proven), so the rotation table is empty — by measurement, not assumption. - *Gates.* web-ui `node --test` 33/33 exact; bundle-aware PHP cohort (WebUiBundleGate, PublicFrontControllerRemovalGuard, ViteAssetsMemo, SharedUiControllerCspNonce, HttpHandlerServeStatic, BrowserE2EGate, LicenseHeaderGuard, NginxAssetLocation) OK 68 tests / 503 assertions — per-file identical to the v0.99.25-era baseline, zero reds, so no era pin rotated; `composer validate --strict` OK. Zero PHP source files touched. README UI-truth line and this entry carry the version flip; `docs/dev/ui-bundle-vendor.md` is version-agnostic and holds. [server pin-wave leg, 2026-11-10] - **Admin and portal error copy now speaks one house voice, and the shared sentences have one home** (review server__copywriter #3-#6, #11-#13, SH-041). Validation and outcome literals across `src/Server/Http/Controllers/**` and `Session/SyncPlay/SyncPlayManager` were normalized: human prose leads capitalized and ends its sentences — the refusal family no longer answers bare "Group not found" on its REST mirrors while the WS frames answer "Group not found." — same-file label/message pairs collapsed to their dominant form ("Invalid email format"; the stale "must be a string" pointer family now reads "must be text"), `id` became `ID` and `chars` became `characters` in operator- facing prose, and field-name collisions keep the wire key visible in a parenthesis where integrators match on it ("Metadata JSON (metadata_json) exceeds the ...-byte limit", "Metadata priority (metadata_priority) ..."). The four streaming segment status values and the hub-enrolment sentence (three verbatim copies) moved into the new `Server\Http\Controllers\ControllerCopy` constants class; those values are byte-identical to the prose they replace, pinned by a sweep test's wire byte-identity leg. HTTP statuses, envelope key sets and the wire error-code set did not move; only human-readable `error`/`message` prose values changed, and every consumer-visible pin (unit tests, the pinned SyncPlay envelope vectors fixture, codeception and E2E legs) rotated in this same commit. Machine-readable `error` tokens (`missing_identity_id` and friends) remain tokenized by doctrine (#14): the code is contract, the copy is ours. [SH-041 / server__copywriter#3-#6,#11-#13, w22L2 2026-11-10] - **`GET /api/v1/media/search` reports the library-wide match count.** The endpoint's `total` used to be the length of the page it returned, so a library holding 400 matches for `dune` answered `total: 50` (the default limit) — and the SPA's "showing X of Y" cap notice, which compares page length against this field, could by construction never fire. `ItemRepository` gains `searchCount()`, a dedicated COUNT leg mirroring the page path statement-for-statement: same mode decision (boolean-mode FULLTEXT, the sanitized-empty LIKE direct route, or the LIKE fallback after a fulltext error), same three-leg artist/album container structure with the primary leg's id-set subtracted the way `mergeSearchResults()` dedupes, and the same failure ladder (container errors contribute 0, a direct-fuzzy primary error propagates). No count statement carries a LIMIT — the number is deliberately about the set beyond the page. Response key set `items/query/total` and every status are byte-identical; only the VALUE semantics of `total` move. The sibling `media/search/by-marker` endpoint now clamps an out-of-range `limit` through the shared `PageLimit` policy like every other paged surface (`PageLimit::MAX` equals the bound it used to reject at 400), while its `around`-window and marker-type 400 arms stay: an invalid window is semantically broken, not oversized. (review server__webux #1-#2, SH-023) [SH-023 / server__webux#1-#2, w22L2 2026-11-10] - **The local-address discovery probe now has one home.** `NatPmpClient`, `StunClient`, `UpnpIgdClient` and `PortForwardService` each carried a byte-identical copy of the `getLocalIpAddress()` / `getLocalIpViaUdpSocket()` pair — the interface scan gated on `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`, the S146/S197 coroutine probe, and the blocking `fsockopen('8.8.8.8', 53)` fallback — plus an identical `inCoroutine()` predicate. The scan and probe bodies moved verbatim into `src/Network/LocalAddressProbe.php` (178 lines, one home); each class keeps its own scan-then-probe step and delegates the probe through a closure that still calls its own `createCoroutineSocket()`, so the documented override seam is untouched and behavior is identical. 300 lines of copies came out of the four classes (measured diffstat); `NatPmpClient`'s now-orphaned private `inCoroutine()` was deleted with its last call site. The `CoroutineSocketFailureContainmentTest` family still drives all four sites through their delegates (it now names the helper), the coroutine-fork inventory map in `ForkInventoryGuardTest` rotated 30 files to 27 in the same commit, and `LocalAddressProbeTest` pins the edge-shape decision table (IPv6 zone ids, bracketed hosts, `0.0.0.0`, all loopback classes), the measured allow-lists for the remaining `net_get_interfaces`/`8.8.8.8` carriers, and the last-colon fallback parse. `PortForwardService`'s `discoverGateway()` LAN-prefix read and `SsdpAdvertiser`'s distinct probe deliberately stayed put — different contracts, not copies. [SH-007 / server__srphp#3, w22L2 2026-11-10] - **The SyncPlay dialect catalog cites its SSOT.** The header docblocks of `src/Session/SyncPlay/Messages.php` and `SyncPlayManager.php` now name `Phlix\Shared\Relay\SyncPlayDialect` (landed in phlix-shared master) as the canonical 19-type catalog and state plainly that the server-side aliases ride the next shared re-vendor — the vendored `0.53.0` does not ship the module yet. Comment-only; zero behavior change. (LW-082-c) - **Operator docs coordinate, inventory and claim currency (w21 L5).** README: the collections cross-references re-anchored to the shipped `CollectionController.php` (the `create()` span, `actorSees()`, `actorManages()`, the PUT ownership-immutability note, `collectionNotFound()` and the class-law bullets — the era's line numbers had all drifted), and the openapi pointer now names the path string to search instead of a rot-prone line number; the cast bullet no longer promises play/pause/seek/stop for **Roku** (its routes are status/send/launch/key — transport is key-press simulation on-device; Chromecast/AirPlay/DLNA keep the full transport claim); the settings deep link corrected to `/app/settings` (no `/app/settings/security` route exists); the manual-install apt mirror gained `ffmpeg` (the installer ships it; without it a manual deploy fails its first transcode) with honest new sentences for `pdo` (built into the Debian php8.3 binaries) and `php-bcmath` (convenience, not a contract member); the env census preamble names the one `scripts/` knob it deliberately omits (`MIN_COVERAGE`, CI-only); `Dockerfile.nvidia` joins the two images already setting `PHLIX_MIGRATIONS_STRICT=1`; the CHANGELOG reading guide now leads with the greppable bold `MIGRATION ANSWER` callouts (3 exist at this tip — measured) and states the forward-only `### Upgrade notes` convention for new dated sections; the library-DELETE row records migration `122_collections_foreign_keys.sql`'s FK cascade; review-ledger handles (SRV-M17/18/19/20, M-5, M27) stripped from operator prose to a single trailing reference, and the touched passages normalized to US spellings (behaviour/judgement/serialising/normalised/memoised → US, allow-list → allowlist). data-model.md: the migration-ordinal claim corrected — a four-digit ordinal sorts **before** the 2xx–9xx files lexicographically (`1000_…` ahead of `999_…`), which is the three-digit law's real justification; table-row review-ID parentheticals stripped. settings-restart-gap.md: the per-key counts re-measured from the table (sixteen live keys — twelve full-apply, five disable-only asymmetric — plus the deleted `hwaccel.probe_timeout` row; the stale "sixteen rows / fifteen keys" intro predates `tmdb.api_key` joining the table) with a rotate-with-the-rows tripwire note, the dangling closing apostrophe deleted, and stale round-labels (S171, R14-06) neutralized. skipped-test-names.md: the deictic "this box reports ~259" generalized to "a typical dev host", S345/S352 round-labels to descriptive provenance. WEBSOCKET_AUTH_CARRIERS.md: round-labels stripped and the open SPEC-flip TODO carries a dated pointer naming its real owner (the phlix-syncplay spec tracker). AUTH_METHOD_LOCKOUT.md / SYNCPLAY_MEMBER_SYNC_NUDGE.md: round-label and orthography passes; untagged ASCII fences tagged `text` (README arch tree, nudge/bridge data-flow blocks). The arch-tree tag rippled into its own guard: `ArchitectureTreeCoversSrcSubsystemsTest`'s fence scan now accepts an optional language tag (the law is the tree's ENTRIES — still fully pinned, guard re-run green), and the S431 census rotated `EXPECTED_PHP_FILES` 2071→2073 measured from red in this commit (the two new seam/DTO test files). - **The blocking-I/O register is current with the shipped runtime (`BLOCKING_IO_EXCEPTIONS.md`).** Exception 2 gained a dated correction: the curated hook mask now reaches every worker — `start.php`'s re-assert became `HookDelivery::enforceAndVerify()` (full-mask `Swoole\Runtime::enableCoroutine` replacement plus a behavioural sibling-tick probe; every worker's start guard re-raises delivery failure loudly, per the "an undelivered hook allowlist is NOT best-effort" doctrine at `start.php:1038-1043`) — so the ≤10 s cURL stall the entry predicted is the live cost, not the pre-fix yield the history measured. The history prose stays verbatim; the stale `start.php` line coordinates carry a dated rotation note (closure `:244-252`, seven arms `:304/:727/:962/:1027/:1094/:1297/:1614` at this tip). Exception 5's 150-word `Bound` table cell was unwrapped into an itemized subsection below the table (content unchanged). A new **Exception 9** registers the `phlix-background-timers` fork — `du -sb` children, whole-`media_items` scans, the due-gated 60 s newsletter sweep (its unbounded pre-fix drain long since rewritten), archive legs and batched retention DELETEs all stall one dedicated `count = 1` process that shares nothing with any latency-critical worker; that containment is the entry's stated justification. (SH-045) - **Operator-facing 500 copy now states symptoms and remedies, not internal names.** The shared fail-closed 500 `error` text `'Auth-method policy check failed'` becomes 'Sign-in-method safety check could not run — no change was saved. Restore the database connection and retry.' on both auth surfaces (admin settings PUT and provider disable); the hub-health stale arm becomes 'Hub heartbeat has not reported since — restart the server.' (embedding the timestamp it already returns, dropping the internal `phlix-hub-heartbeat` fork name); the restart PID-file miss names the actual config knob (`config/server.php: worker.pid_file`) instead of a nominalised "setting is misconfigured". HTTP statuses and envelope keys are unchanged — text slots only. [SH-042 / server__copywriter#7 + #10, w20L2 2026-11-10] - **Docs truth pass on restart, gating, naming and secret-recipe claims.** The `TMDB_API_KEY` census row and the "everything else resolves at runtime" prose now state that the key — env or saved `tmdb.api_key` twin — is resolved at worker start, matching `TmdbApiKeyResolver` and the schema's `restart:true` (a saved change applies on the next worker cycle); the Cast-to-TV feature bullet and the cast troubleshooting section distinguish the three `CastingEnabledMiddleware`-gated protocols from the auth-only DLNA renderer routes (whose real switches are `dlna.enabled` / `dlna.cds_enabled`); the naming-layer map states that user-facing copy says *room* ('Create room' / 'Join room' / 'Room ID') — not *watch party*, which is docs prose — and the quickstart glossary row carries the same correction; the CLI examples prefer the generated-password and stdin forms. [SH-032 / SH-037 / SH-038 / SH-039, w20L2 2026-11-10] - **The served web player carries the next review-cure cycle: `@phlix/ui` v0.99.24 re-pinned to v0.99.25, and the served `/app` bundle regenerated.** [ui review-cure wave 2026-11-09] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.24 → v0.99.25 (tag object `52ea3cb7`, peel `6e1ce2d8`, ls-remote-verified; ui-ci 42702476767 success, headSha-bound). The dependency edges ride unchanged — contracts `#v0.5.7` / syncplay `#v0.1.7`, the blessed pair; the syncplay `#v0.1.8` re-baseline stays its own wave — so the lock delta is surgical: `package-lock.json` 4+/4- (root echo + the ui node triple: version/resolved/integrity) and `package.json` 1+/1-, produced by a real `npm install` under npm 11.19.0 on the S253-pinned node 24.20.0 with `NPM_CONFIG_USERCONFIG=/dev/null` ("changed 1 package"); a second install is a byte-no-op — lock idempotence proven. The vendored `dist/` matches the ui register's consumer-target identities exactly: `phlix-ui.js` `0083016a…`, `player.js` `0dad2a06…`, `phlix-ui.umd.cjs` `cd7be5ef…`, `player.umd.cjs` `50f11c11…`. Payload carried: the ui w15 L2 + w16 L1 review-cure waves (login guidance, anchor music nav, catalog-rails i18n 533→542, SyncPlay contracts-SSOT truth, color-mix fallbacks; stacked-overlay focus routing, SyncPlay reconnect park ladder, socketHint producer truth, three dead wire DTOs deleted) — client-side fixes only, zero server wire change. - *Bundle regen.* `/app` (`public/assets/app`) rebuilt on node 24.20.0, deterministic ×2 (`diff -r` clean between builds), aggregate md5sum-recipe (sorted per-file md5 digest) `6a340d5057990b6faeb38e3027c6b9bc`, 198 files in `assets/`; the stamp reads `0.99.25` (`index-BxGXn4WR.js`, zero `0.99.24` residue tree-wide). The two payload waves churned the module graph: 129 hashed chunks renamed (delete+emit pairs), `index.html` and `.vite/manifest.json` carry the new entry refs, and the ui carrier `PlayerPage-Bp-fkb06` propagates as server chunk `PlayerPage-Bp-fkb06-Cxtb6Pu_.js`. Bundle-aware PHP cohort (WebUiBundleGate 10/56, ViteAssetsMemo 4/7, SharedUiControllerCspNonce 5/35, HttpHandlerServeStatic 10/50, PublicFrontControllerRemovalGuard 9/168, NginxAssetLocation 4/49, LicenseHeaderGuard 5/11, BrowserE2EGate 21/127 — 68 tests / 503 assertions, exact), web-ui `node --test` 33/33, `composer validate --strict` OK — all green, zero era pins moved (the cohort fixture-loads the bundle structurally and holds no version literals). README UI-truth line flips. Zero PHP files touched. - **The OAuth sign-in callbacks now honor the cookie-policy levers (w28L1 round-2, DD M2).** With `auth.cookie.enabled=false` the GitHub and OIDC browser callbacks no longer attach the `phlix_session`/`phlix_refresh` pair: the §7 rollback lever is global now — previously the lever was live on the `/auth` arms but the callback minters consulted nothing, so pulling it still persisted a cookie session on those two paths (the law text claimed otherwise; this entry supersedes that claim — verbatim history stands). Device-marked and foreign-Origin callbacks classify bearer-side per §2.1 and likewise receive no Set-Cookie, and the stored `auth.cookie.insecure` twin now drops the Secure attribute on these paths too (only the `PHLIX_COOKIE_INSECURE=1` env form worked there before). The 302 redirect envelopes — Location, correlation-cookie expiry, the held-account and error refusals — stay byte-ident; zero wire codes changed. Also superseding, as disclosed correction (older text stays verbatim): the dual-emit Added entry's "(rotated on every POST /api/v1/auth/refresh)" describes the browser-class refresh only — the device-class refresh leg never attaches cookies — and its enforcement-coverage framing did not reach the LDAP arms or these callbacks, which this round's suites convict leg by leg (round-2 register, mutation tables). [review w28L1, DD M1/M2] - **`worker.count => 'auto'` now honors a container CPU limit.** The auto arm always resolved to the shipped 14 HTTP workers — but a k8s `cpu: 2000m` limit is a cgroup CFS quota, `nproc` lies about it, and 14 workers plus ~11 sibling processes inside the pod's limits oversubscribed ~2× and put the whole container into an OOMKill lottery (the MySQL connection floor scales with the pool too). `ListenEndpoints::detectCpuQuotaCores()` reads cgroup v2 `cpu.max`, then the v1 `cpu.cfs_quota_us`/`cpu.cfs_period_us` pair, and `start.php` hands the result to `http()`: with a visible quota, `auto` resolves to ceil(cores) clamped to 1..14 — capped at the shipped pool, never a mandate to grow — and with NO usable quota (`max`, the `-1` sentinel, garbage, bare hosts, unrestricted containers, PHPUnit) it resolves to exactly 14, the historical answer byte-for-byte. An explicit `PHLIX_HTTP_WORKER_COUNT` or config int still wins over the quota (precedence law unchanged). `k8s/helm/phlix/values.yaml` documents the worker↔quota↔memory math and the env escape hatch for pinning a count. [server review SH-025 / devops minor 4, w18L1 2026-11-09] - **The heartbeat library contract is pinned against the hub's strict parser.** The hub has parsed heartbeats with `strictLibraries = true` since LW-067 (its `ServerController` passes `true` to `HeartbeatDto::fromPayload`); the server is producer-only, and its `collectLibraries()` was already strict BY CONSTRUCTION — every entry maps through `LibraryRef::fromPayload` (throws on a missing/empty `library_id`/`library_name`) and any throw degrades the whole list to empty, so the hub never sees a half-built entry. LW-064-a makes that law visible instead of tribal: the `HubClient` payload comment states the strict contract, and `HubClientTest:: test_sendHeartbeat_payload_round_trips_through_the_strict_hub_parser` re-parses the captured wire payload with `strictLibraries = true`, so future producer drift reddens here before it becomes 400s at hub audit time. [shared review LW-064-a / srphp info, w18L1 2026-11-09] - **Forward-note (test figures): two w17L3 count claims were wrong; shipped behavior is untouched and nothing is rewritten here.** [server review w17L3 round 2 / DD adversarial review 2026-11-09, minors] (a) The SH-017 Security entry above says `tests/Unit/Auth/AuthManagerAccountRateLimitTest.php` shipped "(6, fallback-store venue …)" tests, and the lane's decomposition register inherited the +1 (32 new/rewritten methods). The file declares exactly FIVE test methods (enumerated at the tip: unknown-identifier charged across IPs, identifier normalization, known-user wrong password, successful-login release, IP-ladder preservation) — the correct total is 31. (b) The lane's body register twice labelled the SH-018 predictable-name extinction scan as "deliberately green at parent — pure tripwire"; re-measured at the parent with tip tests (`DvbtSignalEngineTest::testThePredictableStreamPathIsExtinctFromTheEngine`) is one of the file's three FAILURES — parent src still contains the `/tmp/dvbt_stream_` literal — a genuine FIFTH conviction, not a tripwire (the shipped CHANGELOG's own "1 error + 3 red" figure was already true; the mislabel lived in the register). Enforcement was stronger than recorded, a good-direction error, but the numbers narrative is corrected per the enumerate-exactly law. The review's enforcement Major from the same round (SH-024 durability legs carried zero pins — revert-all measured zero genuine convictions) is closed by `tests/Unit/Docker/ DockerDurabilityEnvPinsTest.php` landing this wave. - **The served web player carries the review-cure cycle: `@phlix/ui` v0.99.22 re-pinned to v0.99.24, and the served `/app` bundle regenerated.** [ui review-cure wave 2026-11-07] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.22 → v0.99.24 (tag object `bb418fe8`, peel `f03447d3` == ui master tip, ls-remote-verified; ui-ci 42343876710 attempt-1 green). v0.99.23 is deliberately SKIPPED — its dist carried a syncplay `#v0.1.8` rotation that regressed inbound watch-party frame handling (ui register TW-ui-23.md; the tag stands as truthful history, its replacement .24 reverts that pin). .24's payload rides the whole review-cure cycle (ui w01 L7…w14 L3: focus custody, gated D-pad hover arms, auth-expiry UX, `window.open` noopener sink, tokenStore guards, copy/ voice sweeps, configured router base, form-UX error recovery) plus contracts `#v0.5.6` → `#v0.5.7`. `package-lock.json` surgical 7+/7- + `package.json` 1+/1- via npm 11 `--package-lock-only` with `NPM_CONFIG_USERCONFIG=/dev/null` on node 24.20.0: root echo + the ui node triple + the contracts edge echo; npm left the `node_modules/@phlix/contracts` resolved node stale at the v0.5.6 peel while advancing the declared edge — hand-repaired to `0.5.7`/`bedfe69b` (ls-remote-verified peel of tag object `eee7e776`), then re-proven lock-idempotent and installed-version-true by a fresh `npm ci` (contracts `0.5.7`, registry `error-codes.json` md5 `5d1783a7…`, syncplay dist md5 `c399c3b4…` == the blessed `#v0.1.7` payload). Fresh `npm ci` installs the tag's `dist/` byte-identical to the ui register's consumer-target identities: `phlix-ui.js` `b000d72d…`, `player.js` `b5bfb4c6…`, `phlix-ui.umd.cjs` `d4e23ca8…`, `player.umd.cjs` `ae409dea…`. - *Bundle regen.* `/app` (`public/assets/app`) rebuilt on the S253-pinned node 24.20.0, deterministic ×2 (`diff -r` clean), aggregate md5sum-recipe (sorted per-file md5 digest) `3ea08650a7c5f267b86b001107d53d6d`, 198 files in `assets/`; stamp reads `0.99.24` (`index-DDAhCsqr.js`, zero `0.99.22` residue tree-wide); ui carrier `PlayerPage-CjKjMmhk` propagates as server chunk `PlayerPage-CjKjMmhk-D6kTWsPX.js`. Bundle-aware PHP cohort (WebUiBundleGate 10/56, ViteAssetsMemo 4/7, SharedUiControllerCspNonce 5/35, HttpHandlerServeStatic 10/50, PublicFrontControllerRemovalGuard 9/168, NginxAssetLocation 4/49), LicenseHeaderGuard 5/11, BrowserE2EGate 21/127, web-ui `node --test` 33/33 — all green. README UI-truth line flips. Zero PHP files touched. - **The webhook event catalogue's "mirrors the shared schema" claim is now enforced by reading the schema.** The vocabulary pin compared the catalogue's literals to a literal copy of the same literals in the test — `detain/phlix-shared` growing an eighth webhook event (or dropping one) would have left the pair green forever, the exact silent-drift class the review logged. `WebhookEventVocabularyTest` now parses the vendored `webhook-events.json` at test time via `SchemaPaths::webhookEvents()` (the live-read idiom `AdminSettingsController` uses for the settings schema) and pins that the schema's `events[].type` lead `WebhookEventCatalog::EVENTS` in order, that exactly `user.login` / `user.logout` / `user.created` may trail them (the widened vocabulary is the phlix-shared cascade's to ship), and that the schema's `reserved` names stay outside the subscribable list. A planted eighth schema event reddens exactly this pin; the catalogue docblock now names the enforcer. - **The web-ui i18n docblock cites its `@phlix/ui` seam by name, not by rotating pin shape.** The header claimed the pin was "a 40-hex sha on phlix-ui master" and the 7th-locale recipe repeated the same stale form — the pin has been a release-tag tarball for several trains, and the era-stamp law is to never bake a shape or version that rotates (verified against the resolved `@phlix/ui` pin; bump "a release whose `LOCALE_MESSAGES` includes the tag"). Comment/docblock only: the built JS chunks stayed byte-identical and only `index-2z1PMaI0.js.map` — whose `sourcesContent` embeds the docblock — regenerated, carried in the same commit, deterministic ×2. (LW-037-b, review-round drift-fix ruling) - **Video playback in the web player presents its full frame rate again: `@phlix/ui` v0.99.21 re-pinned to v0.99.22, and the served `/app` bundle regenerated.** [player fps 2026-11-06] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.21 → v0.99.22 (tag object `abc9566c`, peel `a4ede939` == ui master tip, ls-remote-verified). v0.99.22 stops the player page from re-rasterising live CSS blurs on every video repaint — the poster backdrop and the ambient glow are pre-rendered to small bitmaps (no `filter`/`mask-image` on the playing path) and the hidden chrome drops its `backdrop-filter`. Field-measured on Firefox (RTX 3060 Ti): ~6–9 presented fps → ~24 with the page filters gone, decode healthy throughout (0 dropped); headless Firefox A/B of the built SPA against the live server 17 → 23.75. `package-lock.json` surgical 4+/4- (root echo + the ui node triple), `NPM_CONFIG_USERCONFIG=/dev/null` npm 11 `--package-lock-only`; v0.99.22 moves no declared edge (contracts `#v0.5.6`, syncplay `#v0.1.7` hold). Fresh `npm ci` installs the tag's `dist/` (`phlix-ui.js` `a3a8aa84`, `player.js` `dfa05bb1`, byte-identical to the ui tree). - *Bundle regen.* `/app` (`public/assets/app`) rebuilt, deterministic ×2 (`diff -r` clean), aggregate md5sum-recipe `bcac5f36c3076933aca690087cfab856`, 198 files in `assets/`; stamp reads `0.99.22` (`index-2z1PMaI0.js`), zero `0.99.21` residue; carrier `PlayerPage-BbWPOZn8-BEENg20Z.js` carries `is-preblurred`. Bundle-aware PHP cohort (WebUiBundleGate 10/56, ViteAssetsMemo 4/7, SharedUiControllerCspNonce 5/35, HttpHandlerServeStatic 10/50, PublicFrontControllerRemovalGuard 9/168, NginxAssetLocation 4/49), LicenseHeaderGuard 5/11, BrowserE2EGate 21/127, web-ui `node --test` 33/33 — all green. README UI-truth line flips. Zero PHP files touched. - **Your theme choice now follows your account: `@phlix/ui` v0.99.20 re-pinned to v0.99.21, and the served `/app` bundle regenerated.** [theme persistence 2026-11-06 / S498 write-path law] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.20 → v0.99.21 (tag object `bf70c58f`, peel `01acaa12` == ui master tip, ls-remote-verified). v0.99.21 is the client half of per-user theme persistence this server already serves: the Appearance picker now reads and writes the account's `theme` through `GET`/`PUT /api/v1/users/me/settings` (once-per-login adoption — the server value wins for known ids, any failure is a silent no-op; 1 s-debounced burst-coalescing PUTs with an echo guard so a value that came FROM the server never gets PUT back), discloses catalogue failures and save-locally-only states in the panel, force-refreshes the theme catalogue at mount so a just-enabled ui-theme plugin appears without a page reload, and the Plugins page says plainly that themes land per-user. Plugin-slug writes are admitted by the S498 registry reconciliation this tip shipped at `d37eff05`. `package-lock.json` surgical 4+/4- (root echo + the ui node triple) via the CI-exact toolchain (node 24.20.0 + bundled npm 11.19.0, `NPM_CONFIG_USERCONFIG=/dev/null`); v0.99.21 moves no declared edge, so contracts `#v0.5.6` and syncplay `#v0.1.7` hold. Supply chain: fresh `npm ci` installs bytes-identical to the codeload tag tarball (26,780,271 B; `diff -r` over `dist/` exit 0); `phlix-ui.js` `7e4c16b5`, `player.js` `fcb3596c`, PlayerPage chunk `5b5ac827`, umd `02e3caac`/`d0442d37` — all match the release register. - *Bundle regen.* `/app` (`public/assets/app`) rebuilt with the same pinned toolchain, `emptyOutDir` wipe, deterministic ×2 (`diff -r` clean) — both builds aggregate md5sum-recipe `493eb83323799d9f75a048396322fcd9` (the identical recipe reproduced v0.99.20's recorded `3c4d6380…` on that tree; 200-file set holds; content-hash names rotate with the new ui carriers, incl. `PlayerPage-BTz2s3yI-BZjUkcci.js` `1e84724b`); stamp reads `0.99.21`, zero `0.99.20` residue, zero old-carrier files. Law greps live in the emitted bundle (`index-D0dCfFFm.js`): the `users/me/settings` GET/PUT calls and the `/api/v1/themes` catalogue fetch. README UI-truth line flips. Bundle-aware PHP cohort (WebUiBundleGate, ViteAssetsMemo, SharedUiControllerCspNonce, HttpHandlerServeStatic, PublicFrontControllerRemovalGuard, NginxAssetLocation) 42/365 green against the regenerated tree — exact-to-baseline, zero era pins moved; LicenseHeaderGuard 5/11 and BrowserE2EGate 21/127 green; web-ui `node --test` 33/33. `S253` web-ui.yml (rebuild + byte-exact git-diff gate on node 24.20.0) is the CI attestation. - **URL-contract cure completes: `@phlix/ui` v0.99.19 re-pinned to v0.99.20, and the served `/app` bundle regenerated.** [playback incident 2026-11-06 / URL-contract law] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.19 → v0.99.20 (tag object `bab302e2`, peel `e48d8d02` == ui master tip, ls-remote-verified). v0.99.20 is the client half of the contract this server shipped at `f4f01949`: the player now reads each rung's signed `playlist_url`/`url` VERBATIM instead of deriving names on signed strings, and un-advertised rungs derive strictly PATH-scoped — the suffix-after-`?exp=&sig=` 404 class is structurally dead in the browser too. `package-lock.json` surgical 4+/4- (root echo + the ui node triple) via the CI-exact toolchain (node 24.20.0 + bundled npm 11.19.0, `NPM_CONFIG_USERCONFIG=/dev/null`); v0.99.20 moves no declared edge, so contracts `#v0.5.6` and syncplay `#v0.1.7` hold. Supply chain: fresh `npm ci` installs bytes-identical to the tag (`diff -r` over `dist/` exit 0); `phlix-ui.js` `818e5829`, `player.js` `ca81bb6e`, umd `e32de2fd`/ `27d91992`, PlayerPage carriers `BUnWXFam` `675faa2a` / `DFDk18x-` `46bc42c2` — all match the release register. - *Bundle regen.* `/app` (`public/assets/app`) rebuilt with the same pinned toolchain, `emptyOutDir` wipe, deterministic ×2 (`diff -r` clean) — both builds aggregate md5 `3c4d63806621c95239598384e31e009e` (200-file set holds; 102 content-hash names rotate); stamp reads `0.99.20`, zero `0.99.19` residue. Law greps live in the PlayerPage carrier (`PlayerPage-BUnWXFam-QM3eUQCf.js`): `playlist_url` wire parse, `_master.m3u8`, fallback warn `rung mini-master unavailable`. README UI-truth line flips. Bundle-aware PHP cohort (WebUiBundleGate, ViteAssetsMemo, SharedUiControllerCspNonce, HttpHandlerServeStatic, PublicFrontControllerRemovalGuard, NginxAssetLocation) 42/365 green against the regenerated tree — exact-to-baseline, zero era pins moved; LicenseHeaderGuard 5/11 and BrowserE2EGate 21/127 green; web-ui `node --test` 33/33. `S253` web-ui.yml (rebuild + byte-exact git-diff gate on node 24.20.0) is the CI attestation. - **Playback-lag wave: `@phlix/ui` v0.99.17 re-pinned to v0.99.19, and the served `/app` bundle regenerated.** [wave-playback-fix] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.17 → v0.99.19 (tag object `f9b89669`, peel `a6c9d466` == ui master tip, ls-remote-verified). v0.99.18 was never vendored by any consumer, so this single hop lands two releases: the `.18` RUNGMASTER law — the player loads per-rung `media_v*_master.m3u8` mini-master indexes instead of bare rungs, the only playlist shape where hls.js sees the `EXT-X-MEDIA` audio renditions at all (the hub-phlix no-audio incident's cure) — plus the `.19` self-safety follow-ups: a per-pick rung-probe that falls back to the legacy bare rung on a 404, a synthesized 'Original' audio entry for muxed-audio rungs, and stale audio-menu sync on level switches. The deployed server already writes those indexes (`TranscodeManager` since 1035e7de) and declares their `CODECS` truthfully (994c92af); this commit closes the loop on the client side. The lock diff, cut with the CI-exact toolchain (node 24.20.0, bundled npm 11.19.0, userconfig nulled), is minimal 4+/4−: the root echo and the ui node's version/resolved/integrity triple — v0.99.19 moves no declared edge, so the contracts `#v0.5.6` and syncplay `#v0.1.7` lines stay put. The lock is idempotent under a fresh npm run. - *Supply chain.* A from-scratch `npm ci` after wiping `node_modules` installs bytes-identical to the v0.99.19 tag payload (`dist/` diff -r exit 0; the only tree delta is npm's pack-time `.gitignore`→`.npmignore` rename): `phlix-ui.js feadba01…`, `player.js 9af33118…`, umd `5feaea70…`, and the PlayerPage carriers `kwMAZlDu.js 2aba040e…` / `DAswZ8i8.cjs 14a50c00…` all equal the release register's consumer-target figures. The installed `src/i18n` tree is NOT byte-identical to v0.99.17's — the `.19` fold adds one leaf key, `audioOriginal: 'Original'`, across en plus the seven locale mirrors (523 → 524) — and the web-ui i18n seam pins hold across that addition (node --test 33/33). - *Served bundle.* `public/assets/app/` regenerated from the new lock with the same CI-pinned toolchain (output dir wiped by the config's `emptyOutDir`), built twice, both runs aggregating to md5 `26b10ecc6732dd386d2e9250716498e1` under the printed recipe find public/assets/app -type f | sort | xargs md5sum | md5sum The 200-file set holds (125 content-hashed file names rotate; manifest and index.html follow); the bundle's ui version stamp now reads 0.99.19 (grepped from the built tree — zero v0.99.17 remnants and zero for the skipped v0.99.18). Law greps over the built set: `_master.m3u8` and the `rung mini-master unavailable` warning both live in the PlayerPage carrier chunk — the browser now actually fetches the rung indexes the server has been emitting. The README "UI truth at this tip" line flips to v0.99.19 alongside. The bundle-aware PHP cohort (`WebUiBundleGateTest`, `ViteAssetsMemoTest`, `SharedUiControllerCspNonceTest`, `HttpHandlerServeStaticTest`, `PublicFrontControllerRemovalGuardTest`, `NginxAssetLocationTest`) runs 42/365 green against the regenerated tree — exact-to-baseline, zero era pins moved, nothing rotated from red; the two lockfile readers (`LicenseHeaderGuardTest` 5/11, `BrowserE2EGateTest` 21/127) are green too. S253 web-ui.yml gate is the CI attestation (npm ci + build + git diff --exit-code public/assets/app). - **A silent video now stays silent on paper too: HLS/DASH codec declarations tell the truth about audio.** Under the shipped client contract (phlix-ui 3174b805), a rung's `CODECS` attribute is what announces "this media has an audio track" — hls.js surfaces it and the player synthesizes the single 'Original' entry from it. The mini-master wave made that announcement load-bearing, and the archaeology found the announcement was not always true: `AbrLadder` stamps `mp4a.40.2` into every rung's CODECS unconditionally, while a source with ZERO audio streams encodes through `-map 0:a:0?` — an optional mapping that maps nothing. Silent titles therefore grew a phantom, dead 'Original' track. The fix is one persisted truth plus one renderer rule: job creation now writes the probe's audio-stream count into the ladder JSON (`audio_stream_count`), and when that count is 0 and no shared `aud` group exists, `buildMultiVariantMaster()` strips the audio entry from every `#EXT-X-STREAM-INF CODECS` (master AND each `media_v*_master.m3u8` index — one flag, one builder, no drift), with the MPD representations following through the same S58 `videoOnlyCodecs()` helper, whose docblock now names both manifests. A muxed single-audio source (count 1) keeps its `mp4a.40.2` claim — the segments genuinely carry AAC — which is the shape the player's synthesized entry was written for, now pinned as law on both the creation and regeneration paths. Pre-law persisted rows carry no count key at all: they regenerate byte-for-byte as before (absence means unknown, never zero) — their stale phantom claim lives only in already-cached directories until eviction rewrites them, disclosed rather than silently rewritten under a running job. The `> 1`-gate on `audio_tracks` stays BY LAW: persisting a one-element group would flip every rung `-an` while no `media_a` rendition exists to carry the sound — guaranteed silence; the CODECS attribute is the muxed case's honest carrier. `JOB_KEY_VERSION` and `EncodeSettings::fingerprint()` do not move: the declaration is derived-at-render, and the key hash never covered the variants blob — no re-encode is forced. Mutation-toothed: disabling the strip, stripping on the wrong shapes, dropping the persisted count, or forgetting it on the regen path each reddens exactly its pins. [playback-lag program / AUDIO-DECLARATION law, server half of ui contract 3174b805] - **Playback-lag wave: `@phlix/ui` v0.99.16 re-pinned, and the served `/app` bundle regenerated.** [wave-playback-fix] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.16 → v0.99.17 (tag object `4515df9c`, peel `b958fb57` == ui master tip, ls-remote-verified). The functional delta is the autoplay-mute leak cure the FIELD-hub-phlix no-audio report traced: one `NotAllowedError` autoplay rejection no longer writes `muted: true` into the shared player-store session, which had silenced every later item in the same page life. The lock diff, cut with the CI-exact toolchain (node 24.20.0, bundled npm 11.19.0, userconfig nulled), is minimal 4+/4−: the root echo and the ui node's version/resolved/integrity triple — v0.99.17 moves no declared edge, so the contracts `#v0.5.6` and syncplay `#v0.1.7` lines stay put and the v0.99.16-era hoisted-node advance was not needed. The lock is idempotent under a fresh npm run. - *Supply chain.* A from-scratch `npm ci` after wiping `node_modules` installs bytes-identical to the v0.99.17 tag payload (`dist/` diff -r exit 0; the only tree delta is npm's pack-time `.gitignore`→`.npmignore` rename): `phlix-ui.js e00ff69c…`, `player.js 903b503c…` equal the release register's consumer-target figures. The installed `src/i18n` tree is byte-identical to v0.99.16's — the release is locale-blind, so the web-ui i18n seam pins stay put (node --test 33/33). - *Served bundle.* `public/assets/app/` regenerated from the new lock with the same CI-pinned toolchain (output dir wiped by the config's `emptyOutDir`), built twice, both runs aggregating to md5 `aaefe403ca68a2719feba819ed7f5c5c` under the printed recipe find public/assets/app -type f | sort | xargs md5sum | md5sum The 200-file set holds (107 content-hashed chunk names rotate; manifest and index.html follow); the bundle's ui version stamp now reads 0.99.17 (grepped from the built tree; zero `0.99.16` remnants, and zero 0.99.16 stamps anywhere in the repo's PHP/docs besides history). The README "UI truth at this tip" line flips to v0.99.17 alongside. The bundle-aware PHP cohort (`WebUiBundleGateTest`, `ViteAssetsMemoTest`, `SharedUiControllerCspNonceTest`, `HttpHandlerServeStaticTest`, `PublicFrontControllerRemovalGuardTest`, `NginxAssetLocationTest`) runs 42/365 green against the regenerated tree — zero era pins moved, nothing rotated from red (baseline measured at the pre-bump tip identical). S253 web-ui.yml gate is the CI attestation (npm ci + build + git diff --exit-code public/assets/app). - **php-uv's pin rotates from a nearly three-year-old commit to upstream v0.3.1.** `scripts/install.sh`, `docker/Dockerfile.base`'s `ARG PHP_UV_REF` and the three CI "Build and enable php-uv" workflow steps all carried `670a609` (dated 2023-12-03); they rotate together to `e56d640` (2026-10-02 — the commit the lightweight tag `v0.3.1` points to and upstream HEAD of bwoebi/php-uv at authoring, both re-verified via `git ls-remote` in-lane). The 11-commit delta taken: the issue-118 orphan-ADDREF fix (refcount leaks in the `uv_fs` paths — the substantive one for long-running daemons), PHP 8.6 build + arginfo hygiene, and CI/packaging glue; a skim of the remaining `php_uv.c` hunks shows the socket-to-fd conversion changes are the `XtOffsetOf`→`offsetof` macro rewrite only — no stream/TLS semantics move. The owner had already updated the live host before this entry; the repo change is fresh-install and CI-reproduction parity (HOST-5 addendum #5). `ThirdPartyClonePinGuardTest::PHP_UV_PIN` moves in the same commit — measured red before the re-pin was exactly its three uv legs, green at the 14/244 baseline after; `scripts/ci-browser-e2e-prereqs.php`'s pinning-prose names the new commit. The hub repo's uv pin rotates in its own lane. - **The base-image build chain is digest-pinned; the ~50-minute rebuild tax on unrelated commits is gone.** `docker/Dockerfile.base` FROMed the floating `php:${PHP_VERSION}-cli-alpine` tag, so every upstream re-push of that tag re-rooted the buildcache chain while the Dockerfile itself never moved: measured on consecutive master runs, 50f02166 hit 8 CACHED layers in 38s at 00:47Z, and 33 minutes later ca46ef96 — same Dockerfile, a test-only diff — paid 49m24s with zero cached layers, recompiling Swoole twice (amd64 1365.8s + arm64-under-QEMU 1398.7s). The GHA + registry caches wired in the S163 rounds were healthy and correctly invoked; they simply cannot survive a moving foundation. The `FROM` now pins the immutable multi-arch index digest (`@sha256:66e726c3…`, re-verified live at authoring) with a rotation law in the file's own idiom — deliberate, security-reviewed moves only, the same doctrine as the `SWOOLE_REF`/`PHP_UV_REF` pins below it. `DockerEntrypointTest::testTheDockerReadmeNamesTheBaseImageTheBaseDockerfileActuallyUses` is the tripwire, tightened from tag-only to fail loud if the pin floats or rotates without the constant moving in the same commit; `docker/README.md` carries the precision note. No workflow change: `.github/workflows/docker.yml` needed none — the forensics found its caching fully wired since S163. [HOST-5 / owner brief 2026-11-05: "look where the time is being used … maybe implement caching into that to speed it up"] - **The production install path no longer installs or enables Xdebug, and `--update` now disarms existing enablements.** Owner ruling on the HOST-5 fault evidence: every observed production fault in the swoole era — the 6/6 post-rebuild scratch-probe traps and the 3 pre-rebuild exit-139 worker deaths alike — was a general protection fault INSIDE `xdebug.so` at coroutine teardown (kernel-attributed, zero incidents in `swoole.so`), and the extension sat in EVERY production process at `mode=develop` (HOST-5 Q8; the estate's own CI idiom strips xdebug for the same toolchain-interference reason). HOST-6 verified the manually-applied equivalent live: 0 xdebug segments across all 38 server+hub processes, 0 new faults. Mechanics: `scripts/install.sh` drops `php-xdebug` from the fresh-install apt list (the package self-enables via `conf.d/20-xdebug.ini` on install, so shipping it WAS enabling it) and the whole `zz-xdebug.ini` writer + `XDEBUG_*` trigger machinery is retired — the shipped-disabled posture was the right shape for a debug-only risk, but the crash fingerprint is produced by the extension being LOADED at coroutine shutdown, not by an active debug session. New `phlix_disarm_xdebug()` runs on install re-runs and `--update` (section 2c, beside the swoole skip-gate family, same idempotent-repair idiom): removes `/etc/php/*/{cli,fpm}/conf.d/*xdebug*.ini` enablements plus `phpdismod xdebug`, prints a one-time notice naming what it removed and why, and is fail-soft throughout — missing tools or permissions warn, never abort an update. The package itself is deliberately never purged (dev boxes keep it dormant; `phpenmod xdebug` remains the operator's own deliberate act). README + `systemd/README.md` carry the truth; the docker images are unaffected (they never installed the extension — `docker/php.ini`'s opt-in stanza stays as the documented dev seam); CI coverage tooling is untouched (`phpunit.yml` runs PCOV; the syncplay-e2e job's setup-php coverage driver is CI-side). Historical entries describing the shipped-disabled era remain verbatim per this file's forward-history law. - **The from-source Swoole recipe now builds release v6.2.4 (tag commit `c7e514509451beafd4d70ce5b78943d51e76381c`) instead of v6.2.1.** The owner rebuilt the production extension on hub.phlix at v6.2.4 with the canonical flag set plus temporary debug/trace logging, and HOST-5 verified the rebuild live: hooked-TLS ClientHello write-stall CURED (the 6.2.3 release fixed the coroutine-socket io_uring×TLS read/write bug behind the F-1 stall class), io_uring + uring_socket ACTIVE and healthy on the production kernel, and the update path cannot downgrade the running binary — `phlix_build_swoole` skips whenever the extension is loaded, so this rotation only reaches FRESH installs, bringing them to parity with the ratified production build. Moved together, per the S482 one-commit rule: `scripts/install.sh` clone tag + rev-parse self-verify, `docker/Dockerfile.base` `ARG SWOOLE_REF`, and the guard test's recorded tag/SHA pair (`ThirdPartyClonePinGuardTest` — which measured 2 convictions on the partial rotation before its constants moved). The configure flags are UNCHANGED (Q9 verdict: keep all; `--enable-debug-log` / `--enable-trace-log` stay out of the recipes — diagnostic-only, journald flood). The S200 kernel-floor measurements stay recorded verbatim; a dated note documents that the 6.9 floor was measured on 6.2.1 and is treated as binding on 6.2.4 until re-measured. The tag is lightweight — `git ls-remote refs/tags/v6.2.4` resolves directly to the pinned commit, re-verified at this re-pin; `ARG PHP_UV_REF` untouched. - **The 2026-11-04 symfony advisory flush is cleared — lock-only re-vendor of the five affected `symfony/*` components.** The Coding Standards `Security Audit` job (`php scripts/security-audit-check.php`, which blocks any advisory against `composer.lock`) went red on that day's push CI — the sole red, the other workflows stayed green, and the review register attributes it to the external advisory wave (published 2026-11-04T11:43Z, first conviction at tip `fddfef88`, run 42076212140), deferring remediation to this lock-only lane. The flush flagged 8 advisories across 5 locked packages — `symfony/config` (GHSA-5jff-8h8q-r5qx, XXE through the `XmlUtils` parse path), `symfony/filesystem` (GHSA-6prc-h956-hxjq, path-prefix escape via `://`), `symfony/http-foundation` (CVE-2026-118514 `HEAD`-method bypass; GHSA-3h27-hjv7-fqjx path-rule matching; GHSA-xg2q-4rrq-657q `private_ranges` trusted-proxy handling; GHSA-5mx8-mwrp-5693 host-derived redirect URL generation), `symfony/http-kernel` (GHSA-wg5q-m2ww-f3c9 inline fragments forwarded headers) and `symfony/yaml` (GHSA-hwfg-qwvh-2wfx exponential-time parse) — all patched in the 7.4.23 releases. `composer update` on exactly the five names resolves inside the declared ranges: `v7.4.17 → v7.4.23` (`symfony/config`; the other four were `v7.4.21 → v7.4.23`), `composer.json` untouched — the only root constraint among them is `symfony/yaml: ^7.0`, the rest arrive transitively — the lock diff is limited to the five bumped nodes (30+/30−: version, references, urls, time), the content-hash is unchanged, and the unflagged symfony neighbors stay as they were. Gate evidence: audit script exit 0 (`Security audit passed`), `composer validate --strict --no-check-publish` clean, both phpstan corpora zero errors, both phpcs corpora zero errors, the S180 prober's undecided set still matches `probe-baseline.json` exactly (a vendor-only change rotates no count), and the full local suite battery green across the union of the two ext-swoole host lanes — every observed red (19 files: coroutine/HTTP-client parent segfaults and real-composer child segfaults, signal 11) reproduced identically under the PRE-bump lock in a control clone, i.e. the host's `swoole 6.0.2-dev` build, zero attribution to the bump. - **A freshly created HLS job now encodes its opening window in the background instead of making the first viewer pay for it.** Job creation only ever wrote the playlists; every segment — including the very first ones, whose timing the server COULD control — was produced lazily inside the reader's own request. On a cache-cold boot (the `PrivateTmp` wipe below made that every deploy) FIELD-hub-phlix measured those inline builds at 3.0–6.6 s per 6 s segment: rubber-band lag on open. Now, the moment the job row commits, the creation path kicks the SAME on-demand producer for the top rung's first `prewarm_segments` fragments (default 3, env `PHLIX_SEGMENT_PREWARM_SEGMENTS`, `0` disables) in launch-only mode: the SV-4.1 reserve-before-glob dedup and the segment-concurrency ceiling apply unchanged — a pre-gen can never double-build a segment another worker holds, and on a saturated box it stops early and the reader simply meets the old wait-fast path. The fMP4 publish chain writes the init segment ahead of the first fragment, so the cold-start pair (init + window) lands before the player asks. Best-effort by construction: every pre-gen failure (busy, cache-full, refused params, even an internal defect) is logged and job creation completes exactly as before — the worst case is the pre-change world. Audio-group renditions and lower rungs stay demand-shaped; only the top rung's opening window is predictable enough to pre-generate honestly. [FIELD-hub-phlix; playback-lag cure C6 — cold playback must not transcode inline] - **`PHLIX_SEGMENT_DIR` lets the HLS segment cache live outside `PrivateTmp` — it can now survive restarts.** The cache defaulted to `sys_get_temp_dir()/phlix_hls`, and the shipped systemd unit runs `PrivateTmp=true`: that directory was a per-start scratch namespace, so EVERY deploy/restart silently wiped the whole segment cache and cold first-plays re-paid 3.0–6.6 s per 6 s segment in synchronous inline transcode (FIELD-hub-phlix trace, Nov 4). `config/server.php` resolves `hls.segment_dir` through `EnvConfig::text` — unset/empty stays byte-identical to the historical default; `install.sh` provisions the persistent path (`/var/phlix/hls`: created, owned, retrofitted into existing units' `ReadWritePaths` + `Environment=`, and backfilled into the env file via `phlix_ensure_env_key`, which preserves an operator-set value). An unusable override aborts boot FAIL-LOUD in the master, pre-fork, through the new `SegmentCacheDirectory::assertUsable` (empty / relative / existing non-directory / non-writable / uncreatable each refuse with an operator-actionable message naming the knob) — a silent fallback was deliberately rejected: a quietly broken cache is invisible until playback is slow, which is exactly the incident this knob exists to prevent. The 8 GiB LRU budget, the 3 h idle TTL and the free-space floor key off the SAME resolved directory and govern either location identically (law-pinned against a non-temp path in `TranscodeManagerPersistentCacheBudgetTest`). This commit also lands the HOST-1 T7 must-fix in the same file it touches: the update tail `[ "$prev_commit" = "$new_commit" ] && info …` was `do_update`'s LAST statement, so every REAL commit-changing update returned 1 — under `set -e` the bare call then aborted the script exit 1 AFTER restart and health had already succeeded, making a successful update look like a failure (mirrors the hub's cure: if-form + explicit `return 0`). [FIELD-hub-phlix, HOST-1 T7; playback-lag cure C5 — segment cache survives restarts] - **A knob-forced `PHLIX_LOG_LEVEL=debug` now confesses its per-request cost exactly once at boot.** `PHLIX_LOG_LEVEL` went LIVE in 4ab3ffc3 (R3-08), so a host carrying `debug` lowers EVERY level-driven handler and each served request pays ~10 synchronous Monolog writes — and with `PHLIX_LOG_STDOUT` those writes can block the event loop on log backpressure (INV-A P2). The cure is DISCLOSURE, never an override: `StructuredLogger` gains `verboseFloorDisclosure()` (null unless the resolved knob floor is below info) and `start.php` writes its one warning line through the application channel in the master, pre-fork — one line per boot, none per worker. Unset/empty/unknown values resolve to no override and stay silent by law (a fail-soft inert knob has nothing to confess). [playback-lag forensics INV-A, C4] - **The per-response security-header policy went from two live DB point- SELECTs to at most one store hit per key per second.** `SecurityHeadersPolicy::hstsMaxAgeSeconds()` and `frameOptions()` each ran a live `SettingsRepository::getEffective()` on EVERY response (the repo config-cache covers file defaults only — SettingsRepository.php:112-135 — so both reads hit MySQL on the hot dispatch path; INV-A P6). A per-key in-process memo now mirrors the phlix-hub `MaintenanceGate` idiom — `MEMO_TTL_SECONDS = 1`, strict-`<` expiry, injectable clock for tests, resolved (coerced) values only, so a memoised read is byte-identical to a cold one and a poisoned cache is structurally impossible. The documented trade: an admin edit lands within one second instead of "the very next response" — per-decision-bounded staleness BY DESIGN under the resident- worker law (de573d6e precedent), law-pinned: two reads inside the bound hit the store once, the first read at `t+1` re-reads, the two keys are memoised independently, and the bound itself is constant-pinned. XFO/HSTS wire output is otherwise byte-ident. [playback-lag forensics INV-A, C3] - **Wave 5: `@phlix/ui` v0.99.16 re-pinned, and the served `/app` bundle regenerated.** [wave-5 cascade] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.15 → v0.99.16 (tag object `743a4b38`, peel `4d89b3a7` == master, ls-remote-verified). The lock diff, cut with the CI-exact toolchain (node 24.20.0, bundled npm 11.19.0, userconfig nulled), is minimal 7+/7−: root echo, the ui node's version/resolved/integrity, and the contracts edge the new tag declares — `#v0.5.5` → `#v0.5.6`; syncplay stays `0.1.7@1a80b1d1…` (ui v0.99.16 did not move it). The hoisted contracts node needed the same TARGETED ADVANCE as the v0.99.15-era syncplay precedent: `npm install --package-lock-only` rotated the declared edge but left the stale `0.5.5@e2159816…` node line standing (S450-class npm behaviour); the corrected lock is idempotent under a fresh npm run, and a from-scratch `npm ci` after wiping `node_modules` installs bytes diff-identical to the v0.5.6 tag's `dist/` — trio md5s `5d1783a7…` / `90b79e35…` / `8c92c070…` match the release register (error-codes and mcp-scopes HELD, route-manifest moved with regen #36; zero ErrorCode-union blast radius). Installed `@phlix/ui` matches the v0.99.16 payload 1:1 (1687 files both sides; `dist/` diff -r exit 0 — the only tree delta is npm's pack-time `.gitignore`→`.npmignore` rename): `phlix-ui.js a43ff080…`, `player.js 372d2c51…` equal the register's consumer-target figures. web-ui `node --test`: 33/33. - *Served bundle.* `public/assets/app/` regenerated from the new lock with the same CI-pinned toolchain (output dir wiped by the config's `emptyOutDir`), built twice, both runs aggregating to md5 `92d30c6c36272e3d524c16379ae0fc03` under the printed recipe `find public/assets/app -type f | sort | xargs md5sum | md5sum`. The 200-file set holds; the bundle's ui version stamp now reads 0.99.16 (grepped from the built tree; zero `0.99.15` remnants). The README "UI truth at this tip" line flips to v0.99.16 alongside. The bundle-aware PHP cohort (`WebUiBundleGateTest`, `ViteAssetsMemoTest`, `SharedUiControllerCspNonceTest`, `HttpHandlerServeStaticTest`, `PublicFrontControllerRemovalGuardTest`, `NginxAssetLocationTest`) runs 42/365 green against the regenerated tree — zero era pins moved, nothing rotated from red. - **Endgame gate 2: `@phlix/ui` v0.99.15 re-pinned, and the served `/app` bundle regenerated.** [endgame gate2] - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.14 → v0.99.15 (tag object `74c9e1e3`, peel `36c04fbe` == master, ls-remote-verified). The lock diff, cut with the CI-exact toolchain (node 24.20.0, bundled npm 11.19.0, userconfig nulled), is minimal 7+/7−: root echo, the ui node's version/resolved/integrity, and the syncplay edge the new tag declares — `#v0.1.6` → `#v0.1.7`. contracts stays `0.5.5@e2159816…` (ui v0.99.15 did not move it). The hoisted syncplay node's re-resolution needed a TARGETED ADVANCE: `npm install --package-lock-only` rotated the declared edge but left the stale `08168774…` node line standing (an S450-class npm behaviour); the corrected lock is idempotent under a fresh npm run, and a from-scratch `npm ci` after wiping `node_modules` installs bytes diff-identical to the v0.1.7 tag's `dist/` — the ui release lane's cache-reuse hazard, checked for and proven absent here. Installed `@phlix/ui` dist hashes (`phlix-ui.js 3bad5b07…`, `player.js 330cc731…`) match the v0.99.15 release register's consumer-target figures. web-ui `node --test`: 33/33. - *Served bundle.* `public/assets/app/` regenerated from the new lock with the same CI-pinned toolchain — output wiped, built twice, both runs aggregating to md5 `b6436788924ecf098a546889b705afb1` under the printed recipe `find public/assets/app -type f | sort | xargs md5sum | md5sum` (that recipe re-derives the v0.99.14 era's `0468c3c8…` exactly from the pre-regen tree — the bookkeeping proof the w7-behavior reviewer-Info asked lanes to print). The 200-file set holds; the bundle now embeds the v0.99.15 leave-confirm copy ("Others lose sync if you host") and the ui version stamp reads 0.99.15, both grepped from the built tree — the copy absent from its v0.99.14 predecessor. The README "UI truth at this tip" line flips to v0.99.15 alongside. The bundle-aware PHP cohort (`WebUiBundleGateTest`, `ViteAssetsMemoTest`, `SharedUiControllerCspNonceTest`, `HttpHandlerServeStaticTest`, `PublicFrontControllerRemovalGuardTest`, `NginxAssetLocationTest`) runs 42/365 green against the regenerated tree — zero era pins moved, nothing rotated from red. - **Closeout docs-truth batch (srv-w4 duty; reviews server__pm lineage, server__minortail, server__dba M18RW-R3).** The README "Locked by the admin `reset-password` endpoint" paragraph now states the SRV-M17 truth the prose predates: the password-login and provider legs answer the distinct `403` + `{error, code}` envelope `dd9c848c` shipped (the bare-500 / "not shipped" / "until SRV-M17 maps" interim wording is gone), while the hub-token leg keeps its honest typed-`500` interim — `dd9c848c` never took that leg into scope. `PasswordResetRecoveryReadmeTest` rotated in the same commit as the flip (house one-commit doc/golden-pair law): login/provider sentence pins now REQUIRE the `403` and forbid the retired interim phrasing, and the wire-code pin flipped from README-absence to presence — exactly the flip its own r1 note designed ("only ever flips when a real emitter ships"); new-phrasing teeth measured RED at the stale README (1 failure). Docs currency in-path: the env census's anidb example re-hedged to "no in-repo PHP code" (the installer's shell paths seed and honour `PHLIX_ANIDB_CACHE_DIR` — the m15 survivor); the two present-tense `public/index.php` zombie docblocks (`RequestAuthenticator`, `SignedUrlMiddleware`) and the same family in `RequestAuthenticatorProfileContextTest`'s header are now conditional/historical; `RelayIdentityResolver`'s class header gained its currency line (sibling consumers over one mapping law, not relay-exclusive). Test-side: the chunked HEAD+BODY+END request leg got its own forwarded-strip pin (`RelayConsumerTest`; strip-mutation teeth: exactly 1 red, census inert — zero seed sites added). The relay-127 bucket disclosure sentence (review server__m16b Info) rode the concurrent docs commit `1afd76fc` — recorded here, verbatim history intact. The owed SRV-M17/SRV-M11/SRV-M9 CHANGELOG records land under `### Fixed` below, and the `b1f13dc1` "unrated linkage" typo is forward-noted there. - **Forward-note: the `must_change_password` README truth-flip rode `48e2c560`, and the red-wave belongs there — not to the SRV-M18 cure (review server__pm lineage, majors-R1/T3 minors).** `48e2c560` (a symlink-containment `test(minor)` commit) carried the README reset-password rows reworded to the new hold law — "no route mints a new session" — while the golden that pins those rows, `tests/Unit/Admin/PasswordResetRecoveryReadmeTest`, still asserted the retired sentence "cannot sign in with its password" at that tree: measured at the commit, its test file holds the assertion (line 121) and its README holds zero occurrences of the string, so the pair was split and the test rode RED from `48e2c560` until `d8c834d5` rotated it in the same commit as the provider/mint-seam gates. House law is one commit per moved doc/golden pair (or the rotation ships FIRST). `d8c834d5`'s own entry states "The README and the … docblocks … were reworded to the new law" — true of its four docblocks (exception, repository, admin controller, AuthManager), untrue of the README — `d8c834d5`'s stat lists seven files and README is not among them; it touched no README row at all. Verbatim-history law: both original entries stay exactly as written; this note is the correction, not an edit. No tree change is implied — the tip is self-consistent (the golden measures green at `3e3e47a8`, 5 tests/41 assertions). - **Consumer cascade: `@phlix/contracts` v0.5.5, `@phlix/tokens` v0.2.2, `@phlix/ui` v0.99.14 re-pinned, and the served `/app` bundle regenerated.** - *Contracts registry.* `tests/Fixtures/Contracts/error-codes.json` is re-vendored byte-for-byte from the v0.5.5 tag's `dist/error-codes.json` (204 → 205 codes — additive `common.not_found`, RESERVED; md5 `40c1da48…` → `5d1783a7…`), the `error-codes.pin.txt` sidecar moves `v0.5.3` → `v0.5.5` with it (lockstep law), and `ErrorCodesContractTest::CONTRACTS_TAG` plus its era note rotate in the same commit. The OpenAPI `code`-enum comment now names the v0.5.5 addition; the enum members themselves are UNCHANGED — the server emits no new REST-emittable code, so the exact-equality law correctly keeps `common.not_found` out. No vendored route manifest exists here, so the held `91579683…` (412 tuples) is trivially undisturbed. - *Design tokens.* The composer inline-package pin moves v0.2.1 → v0.2.2 (tag `aa66f2cc`, peels `1a1ee0a8`, ls-remote-verified; tag-archive tarball idiom preserved) — composer.json 3 lines + composer.lock 3 lines (version/url/content-hash), nothing else re-resolved. `src/css/colors.css` is byte-identical v0.2.0 == v0.2.1 == v0.2.2 (sha256 `495d7b48…`, measured from the delivered v0.2.2 tarball), so `ColorsCssParityTest`'s `COLORS_CSS_SHA256` holds; its `TOKENS_TAG`/`TOKENS_COMMIT` constants and the chain-of-custody prose rotate with the pin per the guard's own bump doctrine — the v0.2.1 lane had left them naming v0.2.0, and this commit repairs that drift as disclosed. - *Web UI pin.* `web-ui/package.json` moves the tarball pin v0.99.13 → v0.99.14 (tag object `bfe47d15`, peel `a437b995` == master, ls-remote-verified). The lock diff, cut with the CI-exact toolchain (node 24.20.0, bundled npm 11.19.0, userconfig nulled), is minimal 7+/7−: root echo, the ui node's version/resolved/integrity, and the contracts edge the new tag declares — `#v0.5.4` → `#v0.5.5`, with the hoisted contracts node re-resolved to `0.5.5` at `e2159816…` (the v0.5.5 peel). syncplay stays `0.1.6` (ui v0.99.14 did not move it). `npm ci` from the lock installs the declared tree, and the nested `@phlix/contracts/dist/error-codes.json` matches this commit's server fixture byte-for-byte (`5d1783a7…`). web-ui `node --test`: 33/33. - *Served bundle.* `public/assets/app/` regenerated from the new lock with the same CI-pinned toolchain, built twice after wiping the output — both runs aggregate to md5 `0468c3c827590a2f1b8279ef00af7e1b` (stale-chunk wipe + determinism proven before commit, per the S253 gate's own requirements). The README "UI truth at this tip" line flips to v0.99.14 alongside. The bundle-aware PHP cohort (`WebUiBundleGateTest`, `ViteAssetsMemoTest`, `SharedUiControllerCspNonceTest`, `HttpHandlerServeStaticTest`, `PublicFrontControllerRemovalGuardTest`, `NginxAssetLocationTest`) runs 42/365 green against the regenerated tree. ### Removed - **Four classes nothing in production ever constructed are gone, each with the test file that kept it company.** `src/Auth/WebAuthnProvider.php` implemented the auth `ProviderInterface` but no plugin entry point and no container binding ever registered it — the live WebAuthn path is the ceremony API (`WebAuthnController` + `WebAuthnManager`, gated by `AuthMethodPolicy`), and the adapter survived only on its own unit test. The same sweep result for three zero-reference classes: `src/Common/Events/StructuredLoggerPsrAdapter.php` (PSR-3 bridge over `StructuredLogger`, no construction site anywhere in src/config/start.php/bin/public/install/docker/scripts), `src/LiveTv/ ComskipPostProcessor.php` (the live comskip path is `ComskipLifecycleManager` + `ComskipRunner`), and `src/Media/Metadata/ FanartProvider.php` — nothing ever called `registerProvider` with it, so the `'fanart'` names riding the provider-priority chains and the admin source list are string vocabulary that a future Fanart.tv wiring would register into; the class itself was phantom wiring (its `MetadataManager` docblock claims stay accurate as vocabulary). Gone with them: their four owning test files, and `ProviderFailureLoggingTest`'s Fanart section (the TMDB/TVDB observability legs that file actually exists for are untouched). Census rotated from the measured red: first-party PHP files 2067→2059 (−8: 4 classes + 4 tests, all in this one commit). (server review SH-008/SH-009) - **The webhook notification plugin family is retired — no consumer, no path back without review.** Eight classes under `Phlix\Webhooks\Plugins` (an abstract base plus Apprise, Discord, MQTT, Ntfy, Pushover, Slack and Telegram bridges) were never instantiated anywhere: live delivery is `WebhookDispatcher` / `WebhookService` against subscription URLs, the family's `config/notifications.php` was read only by the family itself, and the review census spent three passes hunting a caller that does not exist. Gone with them: the config file, seven family-only test files, and `TlsVerificationTest`'s plugin data-provider legs (the two live dispatcher TLS legs stay — that law never dies with the family). This also moots the per-channel copy defects the copy review logged for these senders (a time-only stamp on one brand, a doubled title on another, three tone wordings) — there is no per-channel copy left here to fix. The retirement is pinned by the new `tests/Unit/Webhooks/NotificationPluginFamilyRetirement Test.php` — every class gone, the directory holds no PHP again, the orphan config stays gone, and no first-party reference survives — the estate's standard anti-resurrection tripwire. Census rotated from the measured red: first-party PHP files 2079→2067 (+4 new files in this batch, −16 deleted; the file walker counts `config/`, which the arithmetic proves). ### Fixed - **Watch-party join refusals answer with the exact bytes the client listens for (SH-041 round-2).** The SyncPlay join rejection now says `Invalid password` — no trailing period — on both the REST 400 body and the WebSocket frame. The SH-041 punctuation sweep (entry below, era `b1d67a25`) had added a sentence period to this arm; the vendored `@phlix/ui` consumer matches this body by STRICT equality (`isSyncPlayInvalidPassword()` in `src/api/syncplay.ts`) to arm its password re-prompt/focus branch, so every wrong-password join on deployed servers silently lost that branch. The sweep entry claimed "every consumer-visible pin ... rotated in this same commit" — its estate audit covered `web-ui` and `hub` only, and the matcher lives in `phlix-ui/src`, outside that scope; this entry is the forward correction, the older text stands as verbatim history. The round-2 audit re-grepped every string the sweep changed across every consumer repo (ui, hub, mobile, roku, tizen, windows, console — runtime matchers, fixtures and localized catalogs): this join arm is the ONLY broken runtime matcher in the estate; every other client keys on `error_code` or renders the prose as a fallback. The admin rail's dotted `Invalid password.` arms keep the swept sentence form (measured zero consumer matchers). The prose is now byte-pinned as a wire contract by the new `SyncPlayJoinRejectionWireContractTest`, and the punctuation-sweep family list carries a pointer comment instead of this arm — a future copy sweep cannot re-dot it silently. `error_code` values, HTTP statuses and envelope key sets did not move: zero wire-code change. Same round: the SH-007 extraction gains a disclosure-only cure — the `LocalAddressProbe` class header now documents the single measured behavioral divergence from the four originals (when `net_get_interfaces()` itself fails, delegates fall through to the UDP probe where an original short-circuited to null — error-path-only, strictly more lenient), and the delegate docblocks no longer claim blanket parity with every original. [w22L2 R2; server__copywriter SH-041 #4, REVIEW-w22L2server Critical + m1] - **Doc truth: the CHANGELOG marker-count citation is now self-stable (w21L5 round-2).** The SH-043 entry above stated the reading guide leads "with the greppable bold `MIGRATION ANSWER` callouts (3 exist at this tip — measured)". That count was false the moment the sentence itself landed: the entry names the marker in backticks, and a bare-name grep counts prose mentions too — `grep -c 'MIGRATION ANSWER' CHANGELOG.md` measured 4 at the very tip the claim named, and every future mention moves it again (the callout-shape grep measured 1 there; the true callout is S331's). The README "Updating an existing install" paragraph now quotes no marker count at all: it names the callout grep SHAPE (`grep -n '\*\*MIGRATION ANSWER' CHANGELOG.md` — asterisk-joined callouts match, backticked prose mentions cannot), and the `### Upgrade notes` negative pin survives as a DATED snapshot (as of 2026-11-10 `grep -c '^### Upgrade notes' CHANGELOG.md` = 0) rather than a live count — pin shapes and dated measurements, never live counts against an append-only ledger, per the rotate-with-the-rows tripwire law this batch had itself armed. The misquoted historical entry stays verbatim per the house verbatim-history law; this entry is the forward correction. Also: the failure-modes table header in `docs/dev/SYNCPLAY_MEMBER_SYNC_NUDGE.md` now reads "Behavior", joining the US spellings the same batch swept at its neighbours there (round-1's "sweep-verified clean" missed it). Docs-only: zero PHP/test delta; no census movement by design. - **The quickstart journey tells the truth end to end.** The blanket "There is no `.env` or `.env.example` in this repo, by design" was false — `docker/examples/.env.example` exists and is consumed by docker compose interpolation; the sentence now distinguishes the PHP runtime (which has no dotenv loader, still true) from the compose examples. The journey never said how to **play** anything: a new step 4 ("Play your first title") lands between the scan and the household, including the honest expectation that an exotic-codec first play waits on on-demand segment encoding while later plays are instant. No self-service password reset existed anywhere in the user docs; the household step now states the real path — an admin forced-change from **Admin → Users** completed by `php bin/phlix user:reset-password ` on the server, the same CLI line the README's Bad-days section carries. The glossary's room/group map contradicted the README's naming-layers table ("internals say *group*" — older server-internal prose actually says *room*); it now mirrors the README's translation table and names it as the authority. Step renumbering carried through the internal cross-references. (SH-044) - **Managed-HAProxy certificates no longer strand on a copied PEM, and renewing them no longer tears the proxy.** The installer's TLS section used to stop HAProxy for a `certbot --standalone` bootstrap, cat a merged copy of key+chain into `/etc/haproxy/certs/.pem`, bind `crt ` against that copy, and renew via a monthly cron with `--pre-hook stop --post-hook start`: every renewal window left the whole proxy down across the renewal plus a reload, and the copied PEM went stale whenever anything outside the cron rotated the certificate. Capability-measured now: HAProxy 2.9+ (measured via `haproxy -v`'s own version output, never assumed) binds the stable Let's Encrypt live paths directly — `crt "//fullchain.pem" key-file "//privkey.pem"` — so a renewal refreshes the files in place and a reload picks them up; pre-2.9 boxes keep the merged shape, but the deploy hook re-cats it at every renewal instead of letting it rot. Renewal itself moved to webroot: the rebuilt `fe_http` serves `/.well-known/acme-challenge/` from `/var/www/phlix-acme` (token paths validated, everything else under the prefix returns a plain 404), the monthly cron is `certbot renew --quiet --webroot -w /var/www/phlix-acme` with no pre/post hooks, and the deploy hook (`renewal-hooks/deploy/`, auto- discovered) RELOADS and only falls back to a restart when reload is unavailable. `--standalone` survives solely as the bootstrap, retried with a stop ONLY when binding :80 fails because the managed proxy owns it — and the certbot-failure exit now starts HAProxy again before dying instead of leaving the box down. One renewal mechanism per host: the install disables AND masks `certbot.timer` (the distro unit that would race the cron), and uninstall returns the timer to its package default only when NEITHER Phlix renewal cron owner — server or hub — remains. `scripts/install.sh --self-test` adds a non-root, non-mutating conviction leg (version-compare matrix plus bind-render shape cases) so the capability logic itself is gated, and the uninstall doc block states the legacy-copy stranding honestly. [wildcard-cert rotation audit §3.1/§3.4/§3.5 server twin, 2026-11-10] - **Token refresh no longer hands cookies to clients that cannot use them.** The rotation ride on `POST /api/v1/auth/refresh` was unconditional since its introduction (66ae647c): a device-class client refreshing with a body token — a CLI, native app, or server-to-server caller with no cookie jar — also received a `Set-Cookie` pair it could never store, contradicting the §2.1 law that device-class requests never see `Set-Cookie`. The rotation now goes through the same per-request custody classification as the new login/register arms (browser-class AND scheme-eligible); browser cookie refreshes rotate exactly as before and the JSON body stays byte-identical for everyone. [BANK.md cookie-program scaffolding cure 66ae647c / CP-003, w28L1 2026-11-09] - **The hub's pairing reply is parsed by the shared DTO, not by hand.** `HubClient::initiatePairing()` read the claim fields with `is_string(...) ?: ''` / `is_int(...) ?: 600` fallbacks, so a malformed hub response became a "success" carrying an EMPTY claim code that only died later — as `INVALID_CLAIM_ID` at the poll gate, and as an unclaimable dead end in the UI. The tail is now `ClaimResponse::fromPayload()` from `@phlix/shared` — the four camelCase keys ARE the contract, and an absent or wrong-typed field throws at the boundary. The two leniencies pairing has always deliberately tolerated stay: a missing or null `expiresIn` assumes the 600-second claim window, and a missing or null `hubBaseUrl` falls back to the URL actually dialed — both expressed AROUND the parse as explicit `isset` pre-fills (isset covers absent AND null, exactly the old shape) with tests pinning the preserved leniencies AND the new fail-loud forms. Callers already wrap this call in `catch (Throwable)` (the admin pairing action, `scripts/pair-with-hub.php`), so the failure lands in their existing error envelope — no new wire codes. [server review LW-078 / HH, w18L1 2026-11-09] - **Readiness re-sync can no longer resurrect or overwrite terminal transcode job states.** `getJobReadiness()` wrote `completed`/`failed` straight into `transcode_jobs` keyed only by job id — an unguarded UPDATE racing the reaper and the cancel path (lost update: a row the reaper had already retired as `failed`, or a user-cancelled row, could be flipped back to `completed` from a stale disk-marker read). Both statements now carry the predecessor guard `AND status NOT IN ('completed','cancelled')` — chosen over the reaper's `= 'running'` shape on purpose so the legitimate failed→completed recovery persistence stays writable — and zero affected rows is the guard WORKING (logged, cache still invalidated, never an error). Four new pins in `TranscodeManagerTest` (two SQL-literal string pins, one 0-rows-tolerated case with a recording fake, one cancelled-row read-side pin); the two write-guard convictions red on the parent via a stashed-src comparison. (server review SH-021, w17L3) - **The transcode/session schema docs and code comments stop citing things that do not exist, and the dead parts are now tripwired.** `TranscodeManager`'s epoch-guard docblock credited a `completeJob()` method the class never had (the name lives only in the unrelated `LibraryScanCommand`); it now names the two REAL writers — the `getJobReadiness()` marker sync and `reapStaleRunningJobs()`. The `STATUS_*` constants carry per-const truth: `cancelled` has NO writer today (its carve-out and the SH-021 guards are pre-armed for a future cancel path), `queued` is only ever the column DEFAULT, and `pending` is not merely dead but UNREPRESENTABLE — the frozen 001 enum has no such member (kept, not deleted: public constant). The `api_keys` table (001, pre-auth-system era) has zero readers anywhere in `src/`; `sessions.profile_id` (002) is never written by `SessionManager`. No destructive schema surgery — dropping a table or enum member needs its own explicit migration by the forward-only law; this entry plus `docs/data-model.md`'s new "Dead schema facts" section record the truth, and `tests/Unit/Media/Transcoding/TranscodeJobSchemaTruthTest.php` (6 pins, mutation-proven via a planted `api_keys` reader → exactly its own test red) rotates ON PURPOSE the day any of it revives. (server review SH-022, w17L3) - **Docker deploys stop losing application records, job queues and the health start period — with two deliberate behavior deltas disclosed.** (1) `docker logs` was SILENT: the Monolog file handlers write `.logs/` with repo-relative paths INSIDE the image layer (ephemeral), the `phlix_logs:/var/phlix/logs` mount carried only supervisord's captures, and the stdout sink was env-gated OFF with nothing setting it — all three runtime images now ship `ENV PHLIX_LOG_STDOUT=1` (opt out with `-e PHLIX_LOG_STDOUT=0`; the sink ADDS, never replaces). (2) The DB credential boot law in `config/database.php` armed only on the EXACT `PHLIX_ENV=production` string, which no Dockerfile ever set — the images now set it, matching the systemd unit's default tier; the entrypoint's `PHLIX_DATABASE_*→DB_*` mapping keeps the shipped examples silent, and without `PHLIX_REQUIRE_DB_CREDENTIALS=1` the worst case stays the one-time warning. (The sibling posture disagreement — `AuthServicesProvider` treats ANY non-dev env, including absent, as production-shaped by default — is now documented in both files, not unified: each fail-safe direction was chosen for its own blast radius.) (3) The transcode/metadata job queues defaulted to the repo-relative `var/jobs` inside the image — pending work died with the container; all three compose examples set `PHLIX_JOB_QUEUE_ROOT=/var/phlix/data/jobs` on the existing `phlix_data` mount (the helm chart's configmap gained the stdout default; `envFrom` projects it automatically). (4) The server-only example's healthcheck re-states `start_period: 90s` — a compose `healthcheck` block REPLACES the image `HEALTHCHECK` wholesale, silently dropping the Dockerfile's start period and flapping a >1-minute first boot `unhealthy`. (server review SH-024, w17L3) - **The SyncPlay command path now bounds every string it copies.** The WS handlers passed chat bodies, group/member display names and per-item `media_id`/`media_info` queue payloads through verbatim — the only bound was the frame-size default — and the rolling 100-message chat buffer, the 1000-item queue and the snapshot service (which re-encodes and re-UPSERTs the whole state blob on EVERY mutation) made one fat frame a recurring cost, not a one-off. The caps (canonical homes `GroupState::MAX_*`): chat bodies truncate at a 2 KiB display budget, cut with mb_strcut on a codepoint boundary and enforced twice — at the WS boundary (the broadcast carries exactly the stored bytes) and inside `GroupState::addChatMessage` as the belt; display names over 128 bytes are REFUSED by `createGroup()`/`joinGroup()` at the domain choke point, so the REST and WS rails both inherit the law (the join guard sits before the S289 idempotent-rejoin branch, which refreshes the stored name); queue items refuse wholesale under the existing LOW-2 all-or-nothing law above 64 bytes of `media_id` or 4 KiB of ENCODED `media_info` — the encode-probe doubling as the budget check also refuses opaque payloads `json_encode` cannot represent (a 1e999-decoded INF would otherwise throw at snapshot publish). Refusals ride the already-registered `syncplay.create_failed` / `syncplay.join_failed` / `syncplay.queue_limit_exceeded` envelopes — no new wire codes. Six new pins (five wire-level, one belt); red-first at the pre-fix tip was exactly those 5 wire tests; mutations: boundary truncation removed → the 2 chat tests; belt removed → only the belt test (independence proven); each of the four manager guards neutralized → its own test. (server review SH-016) - **Redirect and download headers stop corrupting themselves.** Two encoding defects shared one review family (server review SH-012): the legacy `/music/albums/{name}` and `/music/artists/{name}` redirects `urldecode`d the already wire-encoded path segment before interpolating it into the `Location` header — a double-decode in which a name carrying an encoded CR/LF put raw control bytes into the header (Workerman's Response::encode silently *drops* any header whose value contains CRLF: a 302 with no Location, a dead redirect), and decoded `+`, `&`, `?` corrupted or truncated the target. The segments now ride through verbatim; the SPA route performs the one legitimate decode (its `weird%3Fname` case arrives intact as a single path param, the carry appended with `?`, never a forged second one). And the book download answered with `attachment; filename=""` — a `"` in a book title broke out of the quoted-string (malformed header), non-ASCII names shipped as raw bytes browsers recover divergently, and a CRLF-bearing title could split the header outright. Downloads now emit the RFC 6266 pair: a sanitized ASCII fallback `filename` (control bytes, quote, slash and backslash substituted) plus the authoritative `filename*=UTF-8''` slot, where rawurlencode keeps every byte — including CR/LF as %0D%0A — inside RFC 5987 attr-chars. Red-first at the pre-fix tip (3 convictions across the two files); mutations: urldecode restored → the segment pin reddens; ext slot fed the pre-stripped name → the framing pin reddens; old inline header restored → both book pins redden. - **The newsletter's plain-text copy finally reaches its readers.** The generator has built a `plain_text` sibling for every send since it was written, but the sender declared `Content-Type: text/html` and handed `mail()` only the HTML body — text-only clients and screen-reader fallbacks got raw markup while the second copy died unread in the content array. The message is now a real `multipart/alternative` (RFC 2046 §5.1.4 ordering: base64 text/plain part first, base64 text/html second) with a per-message random boundary pinned token-safe by `tests/Unit/Admin/NewsletterEmailCompositionTest.php`; the composition and the header guard from the SH-011 fix share one `composeEmail()` seam so `sendNewsletter()` holds no inline MIME assembly left to drift. (server review SH-015) - **The weekly newsletter can no longer wedge the background-timer worker — and a week missed while the server was down now actually arrives.** The newsletter timer was a `Timer::add(1, …)` gated on day-and-hour equality with the configured send slot, and inside that hour its drain loop ran `while (getPendingCount() > 0) { processQueue(...) }`. The predicate counted every `status='pending'` row while the select only takes rows with `attempts < max_attempts`, and a send attempt is bumped BEFORE the dispatch — so a worker killed mid-flight (OOM, an operator restarting the very box the loop wedged, or the loop's own earlier spin) leaves rows at `attempts = max, status = 'pending'`: invisible to the select, still counted by the predicate, and the loop spins one DB read per second forever on the `count = 1` background-timers worker — a permanent subsystem outage. The timer was rebuilt in the estate's S340 shape: a new `NewsletterSchedule` holds the slot-aware due-decision and persists `newsletter.last_run_at` in `server_settings`, so the timer is now a 60-second due-gated sweep — which also cures the review's other half, the silent skip of any week the box slept through (the equality gate gave exactly one hour of chances and no boot catch-up). `getPendingCount()` now shares the select's eligibility predicate (`AND attempts < ?`); the drain is bounded per sweep (a batch that stops making progress stops the sweep instead of spinning it, and a 200-send per-sweep cap frames worst-case time); and at arm time rows exhausted under the old shape are reaped to `failed` with an honest note — the precedent the maintenance queue's `start()` sets by reaping orphaned `running` rows. The sweep anchors `week_start` to the send slot rather than the wall clock, so a late catch-up keeps the content window and the migration-125 `(user_id, week_start)` unique key dedup stable. Pinned by the new `tests/Unit/Admin/NewsletterScheduleTest.php` (slot arithmetic, the due matrix, the persistence laws — persist only after the campaign's normal return, a settings-store read failure defers the sweep rather than firing it — campaign ordering, and the bounded-drain budget and no-progress stop) plus three eligibility/reaper SQL-shape pins in `NewsletterSenderTest`. No new wire codes; no migration. (review server__asphp / server__dba SH-cluster) - **Endpoints whose backing service is not wired now name themselves instead of reciting a placeholder.** One sentence — "This feature isn't available on this server right now. Please try again later." — answered 31 distinct unwired-service failures across the web-portal router (Next Up, watch history, My list, ratings, thumbs, watched markers, similar titles, collections, themes, person photos, recommendations, transcode start/status, playback preferences, settings, profile pictures, music-library scans), and the retry promise was false for the case those arms actually serve: a service that was never wired does not come up while you wait. Each arm now answers the same flat `{error}` envelope at the same status (no wire codes added) with copy like "Next Up isn't available on this server. The watch-history service isn't running.", and the operator detail — which surface, which service — is logged once on the HTTP channel inside the new single `serviceUnavailable()` helper. Pinned by the new `tests/Unit/Server/WebPortal/WebPortalRouterUnwiredServiceCopyTest.php`: a source tripwire (the placeholder sentence is gone; exactly 31 helper call sites; every named surface and service appears) plus behavior arms (503, the `error` key alone, names surface + service, no "try again"). Two pre-existing pins that had baked the placeholder text into expected envelopes rotated from the measured red; the census write-denominator moved +1 with the new test's single sanctioned harness Request seed (1181→1182). - **The quickstart's signup steps now name buttons that exist.** Both told the first-time operator to choose **Register** — the portal has no Register label anywhere: the sign-in page offers **Create an account** and the form is headed **Create your account** (verified against the served bundle's `auth.signupLink` / `auth.signupTitle` strings). Following the docs literally meant hunting for a control that is not on the page. - **The vendored `@phlix/ui` runbook's integrity-hash recipe now produces the hash npm actually accepts.** "Package-lock integrity hash" computed `openssl dgst -sha512` hex, but `package-lock.json` requires `sha512-` — the shipped `@phlix/ui` node is literally `sha512-EM2wMfdGg…` — so the documented command could never produce the value the file holds, and an operator who followed it hit EBADINTEGRITY with technically-correct output. The section now shows the base64 form (`openssl dgst -sha512 -binary | openssl base64 -A`, stored as `sha512-`) and points at the preferred path — bump `package.json` and let `npm ci` rewrite the lock (the estate's npm-11 venue) — with the hand-edit reserved for when an install is unavailable; the recipe was executed against the live v0.99.22 tarball and byte-matches the shipped integrity value. The hub-restart section — hub operations inside the server runbook — moved to where it is written (the phlix-hub asset-rebuild runbook), leaving a cross-link here, and the file's headings joined the repo's sentence-case convention. - **Correction to the earlier auth-docblock truth entry: "refuses to boot on every carrier" was an overstatement.** That entry (and the class-header sentence it rewrote) said the boot guard refuses the shipped JWT sentinel on EVERY carrier. True for the serving path — every start.php boot (docker, systemd, bare `php start.php`) runs the guard — but the guard runs only there: `bin/phlix` and two library scripts build the DI container unguarded, where the registration-time `getenv('JWT_SECRET') ?: DEFAULT_JWT_SECRET` evaluates and the sentinel binds into the JwtHandler factory closure. Nothing in those carriers resolves JwtHandler or AuthManager today, so nothing is signed with it — but "can never run" was not the code's law. The `AuthServicesProvider` docblocks now say exactly that (a guarded boot refuses; unguarded CLI carriers bind it but resolve no consumer), and they carry the law forward: any new CLI consumer must run the guard first. Comment/docblock only — zero behavior change. - **The `AuthServicesProvider` docblocks no longer cite the deleted `public/index.php`.** Both the class header and the `DEFAULT_JWT_SECRET` constant described a working sentinel fallback "from public/index.php … to preserve parity for local installs that never set the env var". That CGI front controller was removed by S171, and the four-tier `assertSecretConfigured` boot guard refuses an empty/sentinel/placeholder `JWT_SECRET` on every carrier (and short keys outside dev), so no unconfigured install ever boots on the shipped sentinel — the docblock taught the wrong security posture. Both now state the env-only law and explain the constant as the tier-2 comparison value and test-harness fallback only (review server__seceng-docs #2). - **The nginx template comment no longer normalizes the retiring `?token=` auth carrier.** In the "expose :8097" recipe it told operators the worker "carries auth in the `?token=` query parameter" — the legacy carrier the estate is retiring precisely because any proxy or load balancer that logs request URIs records the JWT in plaintext. The comment now names the bearer subprotocol as the target and `?token=` only as the legacy fallback, matching `reverse-proxy/README.md` and `docs/dev/WEBSOCKET_AUTH_CARRIERS.md` (review server__seceng-docs #3). - **The full bare-metal installer no longer persists a dead `PHLIX_SECRET_KEY`.** `scripts/install.sh` generated `SECRET_KEY` and wrote `PHLIX_SECRET_KEY` into `/etc/phlix/env` (and advertised it in the closing banner) although nothing on the systemd carrier reads it — it is a docker-entrypoint-only alias mapped onto `JWT_SECRET`, and the env file already carries `JWT_SECRET` directly. The dead generation, env line, and banner mention are removed, matching the already-clean sibling `install/systemd.sh`; a high-entropy value that guards nothing only invites future code to trust an unmanaged secret name and doubles the rotation surface (review server__seceng-docs #8). - **Stopping the hub client heartbeat now retires every timer it armed.** The `FIRST_HEARTBEAT_DELAY` one-shot was armed without keeping its Workerman timer id, so `stopHeartbeatLoop()` could only ever cancel the repeating 60s loop — a stop inside the first-tick window left a live one-shot that beat the heartbeat (and rewrote the cross-process state file) after the client was stopped. The one-shot id is now tracked beside the loop id, both are cancelled through a new `cancelTimer()` seam mirroring `scheduleTimer()`, and a fired one-shot retires its own slot first, so stop is exact and idempotent. Pinned by `HubClientTest::test_stopping_the_heartbeat_loop_cancels_both_armed_timers` and `…test_the_fired_first_tick_retires_its_slot_before_a_later_stop` (red-first measured at the parent). (SH-002, review server__asphp #5) - **A caller-set lowercase `cache-control` now suppresses the `html()` default seed.** `Response::html()` checked its pre-set dig case-SENSITIVELY while `json()` had long since moved to the case-insensitive `hasHeader()` dig (L-7 doctrine, RFC 9110 §4.2): pre-setting `cache-control` before an `html()` response emitted BOTH the caller's header and the seed `no-cache` — two case-variants of the same header with conflicting directives on the wire. `html()` now routes the same case-insensitive dig and its seed comment tells the L-7 truth. Pinned by `ResponseTest::testHtmlSeedsNoCacheOnlyWhenNoCaseVariantIsPreSet`. (SH-013, review server__fedev #3) - **The weekly newsletter email now prints the subject it actually shipped, and escapes what it interpolates.** Template sites carried through unescaped: the config-derived `links_base` inside all three `href` attributes (a quote-bearing base URL broke out of the attribute into live markup — the generator only checks the scheme prefix), and the media placeholder cell printed the item name's first raw character while every sibling field was escaped. Separately, the ``/`<h1>` referenced `{$subject}`, which the generator never assigned: a custom subject was visible only in the envelope while the body always showed the default. Those five sites now carry `|escape:'html'` and the subject is assigned before fetch (the envelope value stays raw). Pinned by `NewsletterGrammarTruthTest` renders of the committed template plus `NewsletterGeneratorTest::test_theRenderedEmailCarriesTheEnvelopeSubject` and `…test_aSpecialCharSubjectIsEscapedInTheBodyButRawInTheEnvelope`. (SH-014, review server__fedev #5/#6/#7) - **The plain-text watch-time grammar rule no longer float-compares, and a token-scan tripwire keeps NewsletterGenerator.php loose-comparison-free.** `$hours == 1.0` was the last loose `==` left in `src/`; the word rule now reads the string the reader actually sees — singular only when the rendered number is exactly "1", so the 57–62-minute band that ROUNDS to "1" declines singular by display law, not by float luck (behavior unchanged). `NewsletterGeneratorTest::test_the_generator_carries_no_loose_comparisons` reddens if a `==`/`!=` token ever re-enters the file; `…test_theHourWordFollowsTheRenderedNumber` pins the band. (SH-010, review server__srphp) - **A profile at its concurrent-stream limit is no longer locked out forever by dead sessions.** `StreamSessionService::registerStream()` counted active streams and returned "limit reached" BEFORE running the stale-heartbeat sweep, so once a profile's slots were held only by sessions that had gone silent (closed tab, dropped device), the sweep was unreachable and every new device got `429 stream.limit_exceeded` ("This member profile is already streaming on the maximum number of devices") until something else cleaned the table — observed live: a row 80 minutes past its last heartbeat held a limit-1 profile's only slot. The sweep now runs first, then the single count. Pinned by `StreamSessionServiceTest::testRegisterStreamSweepsStaleRowsBeforeEnforcingTheLimit` (red at parent: the profile was refused; green with the fix). - **A freshly-enabled theme plugin is immediately selectable via settings on every worker, not just the one that served the enable.** The S498 fleet-sync reconciled a worker's theme registry only on the READ path (`GET /api/v1/themes`), while the settings WRITE path validated a submitted theme against the raw process-local registry — so a member who PUT the just-enabled plugin slug to `PUT /api/v1/users/me/settings` could land on one of the ~13 workers that had never served a themes request since the enable and receive a spurious `Unknown theme "<slug>"` 400 whose message listed only the OLD live ids. Cure: `UserRepository:: updateSettings()` reconciles through the very same `ThemeRegistryFleetSync::flushIfStale()` BEFORE validating, and only when the payload actually carries `theme` — theme-free PUTs do zero extra work (pinned). The sync gained a canonical container binding in `PluginsProvider` (one stamp tracker per worker, shared by both arms; `ThemesController` keeps its existing wiring untouched) and is injected into `UserRepository` by a GUARDED `constructorParameter` in `AuthServicesProvider`: subset harnesses and any resolution failure degrade to `null` — the pre-cure validation law, `error_log`-traced — never crash auth boot, and a reconcile whose own stamp read throws is swallowed to the same stale-registry fallback so a settings write never fails for the reconciliation's own error. The reject envelope is unchanged (flat `{"error": ...}`, no new wire codes); after a successful reconcile it simply names the CURRENT live ids, including the slug just enabled elsewhere. [S498 settings-path arm, `UserRepositorySettingsTest` ×7 new + `ThemeRegistryFleetSyncTest` wiring pins ×2] - **A reload during a segment encode no longer leaves a phantom lock that starves later players.** When a detached segment encode is killed mid-flight — the client disconnecting triggers a group-TERM — the encoder's `.part-<hex>` temp (and its sidecars) survive with no owner: the launcher's registry entry is already gone, so the temp-cleaner never runs again. Those orphans counted forever as LIVE encodes at all three accounting sites, so one orphan permanently occupied a slot of the global in-flight cap (`SEGMENT_MAX_INFLIGHT_GLOBAL`), poisoned the prewarm budget (`floor(cap/2)`), and kept the cross-worker dedup snapshot entry alive — a fresh requester for that segment then skipped launching its own encode, rode the ≤30-second piggyback poll, and got a repeating 404 "segment unavailable" — forever, because the accounting is a real-time whole-tree glob (S2: no in-memory state), so no worker restart clears it either. Cure: the detached generator is always wrapped in `timeout -k 10 -s TERM <transcode_timeout>`, so a temp whose mtime age exceeds that wrapper horizon plus a 300-second reap margin is PROVABLY dead — the in-flight accounting step now unlinks such temps (own per-temp hex sidecar family only, never a `{final}.part-*` glob, so a sibling's live temp is untouched) before deciding what counts. The real -time whole-tree glob doctrine (S2) is preserved; only the definition of LIVE changed. When the wrapper is disabled by config (`transcode_timeout <= 0`) there is no provable bound, so the purge stands down and today's behavior is byte-preserved. Disclosed limits: a fresh orphan (younger than the bound) still costs each requester one piggyback-wait-then-404 until the wrapper expires it, and a tighter bound would unlink LIVE temps (ffmpeg writes by fd — SV-4.2 finding #2 slow-but-wanted law). The captured 400 on the aborted request is separately proven NOT to be the application: no app-level arm answers a well-formed `/hls/{uuid}/{segment}` with 400 (bad uuid/filename are 400, signature denial is 401, busy/cache-full/refused are 503, missing is 404); the haproxy session showing `Tr=-1 CD--` was the reload tearing down its own keep-alive connection. [step Fix A′′′, `TranscodeManagerStalePartTempPurgeTest`] - **Multi-audio titles now play sound through the pinned-'Original' quality path, and existing job directories self-heal on first request.** The silence had a second, client-shaped half that today's earlier rung- playlist fix could not reach: the pinned player (hls.js 1.7.4) parses media playlists WITHOUT any `#EXT-X-MEDIA` handling — its router decides "media playlist" from `#EXTINF`/`#EXT-X-TARGETDURATION` and populates `audioTracks` ONLY from the master-path parse — so declaring the audio group inside each video rung's `media_v{id}.m3u8` (earlier today, during the playback-lag program) was spec-invalid for that parser and provably inert: a live haproxy capture against a job written with those bytes showed master → `media_voriginal.m3u8` → video segments only, zero `media_a`/`seg-a` requests. This commit supersedes that approach (the old entry stays as verbatim history): every VIDEO rung now also gets a per-rung mini-master index `media_v{id}_master.m3u8` — the exact master shape restricted to that one variant, group lines rendered through the same single-source method as the master itself — which is what the client loads for a direct rung selection (single-level ABR is a no-op; the `aud` group collects, `DEFAULT=YES` auto-selects, and the already-wired track menu activates). The bare rungs go back to their original no-group bytes, and the index is ALWAYS written — a single- audio/muxed rung gets a legal one-variant master, so the client's URL rule is uniform across both shapes. Pre-existing job dirs (every title transcoded before this fix, the live multiaudio samples included) heal without deleting anything: the regeneration rail fires when a playlist is missing, and its master-only presence check is extended to a rung↔index count — a client's first `media_v*_master.m3u8` request against a pre-fix directory triggers one idempotent full rewrite. The playlist law tests are rewritten to the new law, red-first against the pre-fix tree; `master.m3u8`, the audio-only playlists, and the MPD do not move. (playback-lag program, Fix A′′, 2026-11-06.) - **On-demand HLS segments on a GPU-less server now honour the thread cap and the configured preset.** With hardware acceleration enabled and preferred (both defaults), segment assembly asked the hwaccel path for a capability; on a box without a usable GPU the probe resolves a `software` vendor capability, and that path emitted `-c:v libx264 -preset:v medium` — carrying neither the `segment_encode_threads` (env `PHLIX_SEGMENT_ENCODE_THREADS`) `-threads` cap nor the admin-configured preset (silently overridden to `medium`). Found by LIVE production ffmpeg-argv capture during the 2026-11-06 playback-lag deployment: the measured 3.2–4.7 s/6 s segment pace and 7.9 s cold stall were produced on exactly this path, so the wave C cap had never fired in production. The selection now applies a vendor=software-means-software-semantics rule: a capability whose vendor is `software` (or a probe that resolves nothing) is handed to `buildSegmentCommand`, which owns the thread cap, the configured preset, the crf defaults, and the copy-rung contract. - *Behavior change (intentional, disclosed):* on-demand software video segments encode at the configured preset — `veryfast` by default, admin-adjustable — instead of hardcoded `medium` (faster, honors config; the bitrate/quality tradeoff shifts accordingly, acceptable), and a copy ("Original") rung on a GPU-less box is a true stream copy again instead of a silent libx264 re-encode. - *Cache impact:* none. `EncodeSettings::fingerprint()` inputs (preset/crf/audioBitrate/segmentFormat values) are untouched by the routing, so transcode job keys do NOT rotate and already-produced segments stay valid. - *Law compat:* the hwaccel builders themselves are byte-for-byte unchanged for a given (capability, params) — the pinned hardware-shape suites (`HwaccelSegmentBuilderE2ETest`, the `FfmpegRunnerHwaccel*` family) stay green UNMODIFIED. The selection moved to a public test seam, `FfmpegRunner::resolveSegmentEncodeCommand()` (precedent: `buildDetachedSegmentCommand`, SV-4.2); five runtime-assembled argv tests (software cap+preset, zero-knob arm, copy rung, nvenc shape unchanged, audio-only rung) cover it in `FfmpegRunnerHwaccelTest`. [wave-playback-fix] - **The `hwaccel:probe` table stops introducing a vendor's AV1 name for its H.264 capability.** Every codec-suffixed vendor probe (VAAPI, QSV, AMF, V4L2, VideoToolbox) took the capability's `decoder:` field from `$encoders[0]` — the alphabetical head of `ffmpeg -encoders` — so wherever the AV1 encoder existed the DISPLAY line read `av1_*` beside the probe's actual H.264 `encoder:`. It is display-only (sole consumer: the `hwaccel:probe` command's table), but the whole point of the 73a98001 probe-chain cure was to stop treating alphabetical order as semantics; `decoder:` now takes the same primary-codec expression `encoder:` uses, falling back to the honest first entry only when the probe never saw the primary codec. The same commit extracts the five byte-identical `mapEncodersByCodec()` bodies into one `CodecEncoderMapping` trait — behavior-preserving, the existing selection suite held untouched. Review hygiene (a)/(b) of the playback-lag program, 2026-11-06; pinned by the codec-correct-decoder case in `HwaccelVendorProbeEncoderSelectionTest` (red-first at the parent: `av1_vaapi` ≠ `h264_vaapi`), S431 census rotated 2077→2078 from its measured red in the same commit. - **A directly-loaded video quality playlist now carries its audio.** Titles whose source has MULTIPLE audio tracks encode every video rung `-an` (P3B-S3: sound lives only in the `media_a{N}.m3u8` renditions), and until now the `#EXT-X-MEDIA:TYPE=AUDIO` group naming those renditions was announced ONLY inside `master.m3u8`. A client that loads a rung playlist DIRECTLY — the pinned-'original'/'quality' path, and the stream-COPY original never appears in the master at all — has no supported way (hls.js included) to graft a group from a separate master onto a bare playlist, so it discovered ZERO audio tracks and played SILENT (FIELD-hub-phlix haproxy trace: 18 video segment requests, 0 audio requests). `buildMediaPlaylist()` now declares the SAME group inside every VIDEO rung's `media_v{id}.m3u8`; the relative `URI="media_a{N}.m3u8"` values are already correct because the playlists share the job directory. Anti-drift law: the lines render from ONE method (`audioGroupRenditionLines()`) shared by master and rungs — the existing S57 flag-off master goldens pin the extraction byte-identical, and the new suite asserts rung lines EQUAL the master's (attribute order included), not merely present. Invariants pinned: audio-only playlists gain no group lines (they are the group's members); single-audio/muxed and legacy jobs are byte-identical to the pre-fix bytes; both containers stamp the group (`fmp4` — the shipped default — puts the lines before the `EXT-X-MAP`, which still precedes every segment it governs; `mpegts` — the rollback and every pre-S60 job — likewise). Honest stale-playlist caveat: media playlists are write-once at job creation (the writer's only other caller, `ensurePlaylistRegenerated()`, fires when the LRU sweep removed the whole directory — which then comes back self-describing, so evicted jobs self-heal). An intact pre-fix directory keeps its silent playlists until it is evicted or recreated; the cure for a known-silent title is `DELETE FROM transcode_jobs WHERE media_item_id = ?` — the next playback request rebuilds the job with the fixed playlists — and `SELECT id FROM transcode_jobs WHERE variants LIKE '%"audio_tracks"%'` enumerates the stale multi-audio set. `JOB_KEY_VERSION` was deliberately NOT bumped: invalidating every cached job fleet-wide to fix a playlist-text defect is the bigger hammer. Fix 3 (playback-lag program), 2026-11-06; `TranscodeManagerAudioGroupPlaylistTest` (9 cases, red-first 6/9 at the parent, mutation-toothed) with the S431 census rotated 2076→2077 from its measured red in this same commit. - **A software-encode box keeps its on-demand segments responsive: the creation-time prewarm window may now hold at most half the global segment ceiling, and each software libx264 encode carries a bounded `-threads` cap.** FIELD-hub-phlix measured 3.2–4.7 s per 6 s segment on a 12-core GPU-less host, worst a 7.9 s inline seg0 answer: the C6 head start stacked three prewarm encodes at the SAME full ceiling the reader competes against, and neither x264 nor the HEVC decoder was told how many threads to spawn (each auto-spawned ~cores+2 — 40+ runnable threads under contention). Wave A′: prewarm-initiated encodes are tagged on disk (`{final}.part-p<hex>`, disjoint from the normal `.part-<hex>` family because a `p` is no hex digit), the summed live glob counts them, and the per-segment budget is `floor(cap/2)` — cap 4 → 2 prewarm slots, cap 8 → 4 — over budget the window skips SILENTLY (job creation never fails; the reader keeps the FULL ceiling and the missed segment stays demand-shaped). Wave C: `buildSegmentCommand`'s software libx264 branch appends `-threads N`, N from `config/ffmpeg.php`'s new `segment_encode_threads`, default `max(2, floor(availableCpuCount()/3))` (portable helper: nproc → /proc/cpuinfo → 1), env `PHLIX_SEGMENT_ENCODE_THREADS` overrides and a literal `0` disables the flag (M-SERVER-34 posture). This is contention fairness, not throughput magic: fewer encoder processes AND bounded threads per process. Deliberately absent on the copy passthrough, the audio builders, libx265, and every hwaccel builder; argv is persisted per job (`transcode_jobs.segment_params`), so existing jobs keep their stamped shape — the knob is runner-level and `EncodeSettings::fingerprint()` is untouched. Goldens rotated from the measured red: the two byte-identity pair tests in `FfmpegRunnerSegmentFormatTest` now pin the cap OFF (0) so they keep proving the literal pre-C string (and the new `-threads 0` path re-proves byte-identity end to end); wave-C shape pinned separately with an explicit 4-thread golden. Census rotated 2075→2076 in its own disclosed late-rotation commit (the adding file was the probe lane's `HwaccelVendorProbeEncoderSelectionTest.php`). No new wire codes, no new PHP files in this entry. [playback-lag program / waves A′ + C] - **A hardware-accelerator box now encodes each ABR rung with the encoder for that rung's codec — it no longer silently splices AV1 into H.264 rungs.** `ffmpeg -encoders` lists its encoders alphabetically, and the VAAPI/QSV/AMF (plus the V4L2-request and VideoToolbox) probes stored the first entry of the filtered vendor list as the capability's encoder. `av1_vaapi` sorts before `h264_vaapi`, so on a GPU host the segment builder's `-c:v <capability->encoder` emitted the AV1 encoder for every rung, whatever the ladder asked for — the H.264 playlist would carry AV1 video no client advertises for it. Capabilities now carry a codec-correct `encoders_by_codec` map parsed from the same listing, and `buildHwaccelSegmentCommand` resolves `encoderForCodec()` by the rung's generic codec (nvenc was already prefix-hardcoded and is untouched); the primary `encoder` field stays as the h264-first legacy view. Red-first with a fake-ffmpeg alphabetical listing across all five vendor families plus per-rung splice pins in the runner test (late-returned repair lane; the earlier claimed commits 36095810/8097b859 were phantoms — the landed object is local commit `73a98001`, CHANGELOG recorded forward here per the verbatim-history rule). [playback-lag program / VAAPI probe fix] - **Core update check self-heals when the Fastly-hosted marker leg times out — it now falls back to `api.github.com` (mirrors hub `ad97e0cd`).** The owner symptom "Update check is not working — read 185.199.111.133:443 timeout after 10 seconds" is a dead Fastly anycast POP, not a dead marker: `raw.githubusercontent.com` answers from `185.199.108-111.x`, and Workerman's `AsyncTcpConnection`/ `ConnectionPool` dials ONLY the first `getaddrinfo` answer, so one dead POP poisons every check on the operator's host regardless of the other three addresses (curl survives there because it tries them all). The marker leg is unrecoverable from such a host by retry alone. So a FAILED fetch of a raw-github marker now re-issues ONCE against `https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={ref}` with `Accept: application/vnd.github.raw` — that host lives on GitHub's own `140.82.x` infrastructure, NOT Fastly — and the raw-accept response is the bare file bytes, so `MAX_BODY_BYTES` and the downstream `MARKER_PATTERN` are unaffected. One fallback leg, no ladder, running through the SAME single-shot `$complete` latch, so a caller still gets exactly one completion; a fallback failure combines both messages (`<primary> (fallback: <secondary>)`); a successful primary leg still costs exactly one request; non-github URLs — an operator mirror via `PHLIX_UPDATES_MARKER_URL` or the `updates.marker_url` `server_settings` override — deliberately get NO second request. The change lives inside `AsyncVersionMarkerFetcher`, so the `VersionMarkerFetcherInterface` and the config surface are unchanged; error provenance is unchanged too (`vendor/workerman/http-client/src/ConnectionPool.php` `read <addr> timeout after <n> seconds`, surfaced as `CoreUpdateStatus.lastError` by the admin updates controller). - Tests: `AsyncVersionMarkerFetcherTest` grows 16→24 — a raw-github error falls back and succeeds; both legs failing combine their messages; a non-github error does not; a github success does not; the byte cap re-applies to the fallback body; the ref/path are carried verbatim for master / a version tag / a nested path. The `CapturingHttpClient` double now records every leg, with its scalar `$url`/`$options` still mirroring the last request so the single-leg tests read unchanged. Mutation probes M1 (strip the fallback: exactly the six fallback-dependent tests red), M2 (widen the trigger to any URL: the four non-github completion pins red) and M3 (drop the Accept header: exactly the fall-back-and-succeed test red) each redden their covering tests. - **Dev-channel plugin installs work again: the legitimate GitHub archive→codeload hop is now followed as a controlled re-request.** The owner symptom "installing theme plugins from the dev channel fails with HTTP redirects are not allowed (received HTTP/1.1 302 ...)" was universal, not host-specific: GitHub answers EVERY `github.com/{owner}/{repo}/archive/{ref}.tar.gz` URL with a 302 hop to the canonical `codeload.github.com` artifact location, and the R20-02 SSRF law rejects any 3xx chain. `HttpInstaller::downloadToTemp()` now consults the new pure predicate `vettedGitHubArtifactRedirect()` BEFORE the rejection: the hop is taken only when the chain carries exactly one status line, it is 301/302/303/307/308, and its ONLY `Location:` names an `https` URL at host exactly `codeload.github.com` (no port, userinfo, query or fragment) whose path is the same owner/repo under `tar.gz/` (or `zip/`, matching the entry's kind) with a non-empty ref. The vetted target is then RE-REQUESTED as a second leg — the stream wrapper's `max_redirects=1` is untouched, nothing is "followed" blindly — and the hop leg is terminal: every gate (redirect rejection, error status, declared-size cap, byte pump budget, artifact digest, extraction) runs on the hop response and a 3xx there is rejected byte-identically. Following GitHub's canonical Location rather than pre-rewriting to codeload's raw alias is what keeps the fetched bytes equal to the catalog `artifactSha256` pins (computed over the codeload tarball); proven live at ship gate by installing `detain/phlix-plugin-phantom-violet-theme@HEAD` through the shipped path (sha256 d4dc2bb3e7b212fa3e33c2ac1e867d34dd30292c4b1f16a6e3a83797f112cb4f matched). Pinned by the 33-case accept/reject table in `HttpInstallerGitHubHopTest`; the unchanged-law e2es in `HttpInstallerRedirectE2eTest` and the M-SRV-38 pins in `HttpInstallerBoundedDownloadTest` stayed green unmodified. - **The F-1c relay-role laws gained the two teeth a review found missing, and a stale docblock sentence was corrected.** Review 2026-11-05/REVIEW-F1c measured that an added second per-worker `$worker->eventLoop = Select::class` override (its probe MT-D) slipped all six guard clauses while the worker census kept counting the now-inert delivery call; the guard now pins the override EXCLUSIVE to the relay-tunnel registration (assignment-target whitelist, Select bound exactly once — MT-D reddens it, mutation-proven). The bootstrap additionally pins, as a source-scan law, that start.php never references the `Workerman\Coroutine` namespace: the coroutine Context class memoizes its driver from `Worker::$eventLoopClass` at first load, so a master-path load would hand the Select relay child the Swoole driver and crash-loop its boot on exchangeArray()-on-null before any belt can fire (exactly-one-red per new law; tree behavior unchanged — tests + prose only). `SwooleRuntime::tlsHookFlags()` now tells the F-1c truth: the relay worker is born unhooked and excludes nothing by mask; `relayTunnelHookMask()` stays belt-only. Guard suite 6→8 tests, baseline 8/8 green. [F-1c-minors; REVIEW-F1c M1-M3] - **The relay tunnel now runs on the event-loop regime that actually completes its TLS handshake in production (F-1c).** The F-1b correction above left the tunnel stalled because every hook-side exclusion still lived inside a process whose master had ALREADY swapped the openssl handlers before forking — the swap is sticky on this Swoole build. The master now never pre-enables coroutine hooks; each hooked worker still installs and behaviourally proves the curated allowlist itself (S433 unchanged for http/ws/heartbeat/timers/ managed/natpmp), and the relay-tunnel worker is born unhooked and runs the Select event loop (`$worker->eventLoop = Select::class` at its registration) — the one regime measured on the production host to put the full ClientHello + Certificate/Finished flight on the wire and have the hub decrypt the HELLO. The re-assert closure role-gates Select children out of hook delivery (the seven-call census stands; the gate is in the shared body, no wiring removed). `relayTunnelHookMask()` remains belt-only (tested, unwired) for a future build honoring bit-level swaps; the corroboration gate, entry watchdog and 20 s HELLO_ACK deadline all stay armed as the net. Guard rule 6 rotated from the F-1b narrowing pin to the F-1c role-gate law (Select binding, closure gate before the delivery call, master-enable absence, global Swoole-loop preservation, unwired helper — each clause mutation-proven to redden exactly once). [F-1c] - **Forward correction to the entry below (F-1b design (ii) measured inert — belts now carry the bound, full cure is an open architecture decision).** Host forensics after deploying the above re-ran the identical dial on the production box across the full mask matrix: the relay worker's per-bit `SSL|TLS` exclusion (design (ii)) still strands at `bytes_sent:1533` / `data_segs_out:1`, as does a master-side mask whose TLS bits were never enabled anywhere and a verifiable `Runtime::setHookFlags()` replacement — once ANY Swoole hook is live in the process the openssl swap does not revert, and the async handshake's final flight stays stranded. The stream's crypto meta is populated (`TLSv1.3`) inside that stall, so the corroboration gate (belt 1) cannot discriminate the incident's shape either. The only client-side regime PROVEN to complete is a worker in a process that never called `enableCoroutine`, on the Select loop (full handshake on the wire, HELLO delivered, hub-side reject logged). What the entry above DOES deliver live: the H3-2 silent-ESTAB is dead as a class — production now shows the HELLO_ACK deadline reaping the stalled tunnel at a measured 20.006 s with a bounded backoff reconnect, converting the infinite silent hang into a visible, self-healing cycle. The tunnel still never establishes until the architecture decision lands (hookless-worker bootstrap, separate hookless tunnel process, or a coroutine-native TLS client); the mask narrowing and its wiring stay in place as declarative per-bit intent and forward-compat. [F-1b-correction; FIX-RELAY-TLS-CLIENT-B register 2026-11-05] - **The relay tunnel no longer reports a false TLS "connected" and sits on a silent ESTAB on the production host — the in-regime re-cure of F-1.** The driver fix above missed its target regime: production runs the `Events\Swoole` loop under the curated `0x42fe` hook mask, which INCLUDES `SWOOLE_HOOK_SSL|TLS`, so `stream_socket_enable_crypto()` is the coroutine-hooked variant — it returns TRUE once the ClientHello has merely landed in the coroutine's write buffer (measured in-regime again: `onConnect` fires +16 ms after dial while kernel accounting stays frozen at `bytes_sent:1533 data_segs_out:1` for the whole 26 s window; the HELLO strands un-sent too). F-1 armed its handshake watchdog only on the `0` (incomplete) branch, so a false TRUE walked straight past it into an unbounded silent ESTAB logging a lying "connected; sending HELLO". The root cause is fixed at the bootstrap: the relay-tunnel worker narrows its own hook mask to `0x429e` (`SwooleRuntime::relayTunnelHookMask()` — the runtime mask minus the TLS stream bits) so `enable_crypto` is native, the exact semantics the Select-loop proof of F-1 ran under (the vendored `baseRead` docblock confirms the buffering hazards are hook properties, not loop ones). Blast radius is that one single-connection worker; the loop-static seams (blocking-curl TLS branch, TCP-hooked MySQL pool) are untouched. Three independent belts hold regardless of regime (F-1b): (1) completion must be corroborated by the stream's crypto meta before `onConnect`; (2) the handshake watchdog is armed at `beginTlsHandshake()` ENTRY, before any return branch — and its armed-sentinel is `?int`, because the first id from `Event::delay()` IS 0 and a `> 0` style check silently treats an armed watchdog as unarmed (M-S1); (3) `RelayConsumer` arms a bounded 20 s HELLO_ACK deadline right after sending HELLO, so even a fully believed false-positive tears down into the normal bounded-backoff reconnect instead of lying connected forever. Measured red teeth: stripping corroboration convicts exactly 1 test, the entry-arm 5, the sentinel regression 3, deadline wiring strips 1 structural + 1 semantic each, and the start.php narrowing strip convicts its guard test (mask-identity teeth are CI-only — no swoole on the dev box). Plaintext and local legs remain byte-identical, delegation-pinned. Zero wire change; no new error codes. [F-1b; HOST-3 H3-1/H3-2; REVIEW-F1 M-S1] - **The hub relay tunnel's `wss://` TLS handshake no longer strands mid-negotiation.** On the production host (PHP 8.5 / Swoole 6.2.1) the relay dial to `wss://hub.phlix.is.cc:8802` froze with the kernel accounting for exactly one data segment sent (the 1533-byte ClientHello) and the hub's flight received, ACKed and never answered — a silent ESTAB the worker sat on for 26+ minutes, while a blocking-socket probe to the same endpoint completed TLSv1.3 fine. The async path, not TLS, was broken: Workerman 5.2.3's `AsyncTcpConnection::checkConnection()` re-enters an incomplete `stream_socket_enable_crypto()` on a READABLE watch only, and its private `checkSslHandshake()` does not re-arm at all after the first inbound pass — so when the client must WRITE its flight triggered by that pass (TLS 1.3 `Finished`), nothing ever drives it. The relay now dials TLS through `Phlix\Hub\TlsRelayConnection`, which re-enters the handshake across the inbound→outbound alternation a real flight exchange produces, arms a single transient writable pass per inbound event (never a permanent one, so a drained socket cannot busy-spin the level-triggered loop), treats a failed/EOF pass and a bounded 15 s handshake watchdog as terminal (`destroy()` → `onClose`, letting the relay reconnect instead of parking), and fires `onConnect` — and so the "connected; sending HELLO" trace — only after a genuine handshake completion. Plaintext (`ws://`) and local legs are untouched (the subclass delegates verbatim to the vendored parent when `transport !== 'ssl'`). Zero wire or protocol change; no new error codes. (F-1; F-2/F-3 dispositions.) - **A poisoned `segment_params` row can no longer 500-storm every segment of a job.** The per-request on-demand segment build (`TranscodeManager:: ensureSegment()` → `FfmpegRunner::argvParam()`) throws `InvalidArgumentException` when a job's PERSISTED segment params hold a value outside the ffmpeg argv allowlist (hand-edited row, restored backup, older generator) — but the HLS and DASH segment serve arms caught only the two `RuntimeException` siblings (`SegmentBusyException`, `SegmentCacheFullException`), so that argv-boundary refusal escaped to the router as a per-segment 500 and hls.js/DASH clients hammered it as a retry storm exactly on the legacy jobs the playback-lag forensics (INV-A P3) fingered. Each serve arm now answers the codebase's transient envelope — 503 + `Retry-After: 2` (the 063c59b6 LiveTV not-materialized precedent), flat `{error}` body, no new wire code — plus ONE operator disclosure `error_log` line naming the job id only; the exception's param-slot prose is deliberately not forwarded and persisted param values never reach the log. Red-first at parent: the four new controller pins errored verbatim with the escaping IAE; stripping the new catch arms reddens exactly them again. [playback-lag forensics INV-A, C2] - **A watch party no longer dies mid-film at an hour of host-command silence.** The :8097 stale-group sweep judged liveness on `GroupState::$lastActivityAt`, which only host-command paths (join/leave/ set-host/media/play/pause/seek/queue/chat) stamped — the 5 s `playback_sync` position reports that ARE a room's only traffic during a movie counted as zero activity. Past `GROUP_TIMEOUT` without a command the next 300 s sweep tore the group down (INFO "Group timed out due to inactivity", snapshot row deleted, every member's next report answered NOT_IN_GROUP — silent mid-film death, rejoin churn, backward-jump replay). `recordMemberPosition()` now stamps group liveness beside the position store, mirroring the hub relay (immune by construction since it stamps roomActivity on every echo); the defect was orthogonal to the Nov-2 anchor-advance cure and as old as SyncPlay itself. Law pins: a group seeded stale whose member reports now SURVIVES the sweep whole, and genuine silence past the timeout still tears down (a report is liveness, not a liveness license). [playback-lag forensics INV-B, C1] - **The header-name census tripwire reads code, not prose.** `CorsManagerTest`'s SRV-M16 tripwire walks `src/` for client-reachable header reads (`getHeader('X-*')` literals and `$_SERVER['HTTP_*']` reads) and forces each into the preflight-advertised allowlist — but the scan ran on raw file source, so a mere docblock or line-comment mention of a header could force a browser-advertisable entry for a header nobody actually reads. The scanner now strips `T_COMMENT`/`T_DOC_COMMENT` token-wise before matching (the `MintSeamLawTripwireTest` token-walk idiom) and a new pin proves prose contributes zero while real call sites still count. Measured: the `src/` read-set is unchanged (9 headers before and after) — hardening, not relaxation. [review server__fedev, INFO-SERVER-53] - **INFO tail: a dead WS auth superglobal rail is gone, and the plugin-update rethrow stopped laundering transport prose into client JSON.** From the INFO-SERVER review-queue tail sweep. (1) The deprecated `SyncPlayAuthMiddleware::onConnect()` TCP-accept rail — a `$_GET` reader and superglobal stasher on the auth class, unreferenced in src/ outside its own docblock, whose advertised close codes were structurally never emitted (its private close helper discarded the code argument, and the live handshake-stage gate `authenticateConnection()` does not use the helper at all) — is deleted together with the close helper, the three zero-reader 4001-4003 public constants, the stale docblock example, and the dead-path test pin that exercised it. The R17-02 access-only law the rail duplicated stays pinned on both live validation rails (handshake gate + static validator), untouched legs. (2) `PluginUpdateService::fetchLatestVersion()` laundered transport errors into `new \RuntimeException($e->getMessage())` — upstream-URL prose that then reached admin client bodies through every downstream echo (the checkUpdates row `error`, the update leg's 404 copy). The rethrow now carries a fixed curated copy while class, message and repo go to the PLUGINS log only, previous exception preserved; zero new wire codes, envelope shapes unchanged — the E2 law exactly as the E2W wave's `internalFailure()` ships it. [review server__secnet server__secaudit, INFO-SERVER-32 / INFO-SERVER-62] - **Hub-enrollment refusals now answer the repo's canonical flat `{error, code}` envelope.** The three refusal legs on POST `/api/v1/auth/hub-token` carried an HTTP phrase (`'Service unavailable'`, `'Bad Request'`, `'Unauthorized'`) in `error`, and the four legs on POST `/auth/identities/link/hub` carried snake_case machine tokens (`hub_not_enrolled`, `hub_token_required`, `hub_jwt_invalid`); both shapes also shipped an off-contract third `message` key. All seven now route through `Response::error()`: the human-readable sentence lives in `error`, the dotted code in `code`, and no stray key remains. The codes themselves — `hub.not_enrolled`, `hub.token_required`, `hub.jwt_invalid` — are pre-existing contracts-registry members and are byte-ident before and after: zero new wire codes, and first-party consumers localize by `code` (none text-matched the old `error`/`message` values). The spec now lists the `401`/`503` responses on both paths against the shared `Error` schema. Pinned by a new exact-body-shape `HubEnrollmentRefusalEnvelopeTest` (7 tests, full-body `assertSame` — re-adding `message` or restoring a phrase/token `error` reddens); the existing code-only pins in both sibling suites survived unmodified. [review server__secnet, SRV-M22; register: `.reviews/2026-11-03/FIX-SRV-M22.md`] - **Direct-play stream limiting now honours the canonical `X-Phlix-Session-ID` header.** Both session-id ladders — `StreamLimitMiddleware::getSessionId()` (router/HLS path) and its pre-router twin `PreRouterFastPaths::getStreamSessionId()` (direct `/media/{id}/stream` path) — previously knew only the legacy `X-Session-ID` spelling, while the estate's first-party clients (the API client and the transcode module, on every request) send exactly `X-Phlix-Session-ID`. A header-only client therefore fell through to the synthetic (profile, device) bucket, so the same physical stream could occupy different slots depending on which path answered it — undercutting the shared-bucket law the M-3 synthetic default exists to enforce. The ladders now read query → canonical header → legacy header, identically on both paths, with the legacy spelling kept working as a fallback; +5 conviction legs (canonical honoured on both paths, legacy fallback pinned, precedence query > canonical > legacy pinned on both). The carriers stay client-declared by design: the M-3 property is the synthetic-default-on-omission, not id secrecy — stated at the ladder docblock. [review server__fedev, INFO-SERVER-18] - **Forward-correction: the chunked-head-leg forwarded-strip pin now actually ships.** The closeout-batch entry above (commit `947f7de8`) asserts a test-side companion — "the chunked HEAD+BODY+END request leg got its own forwarded-strip pin (`RelayConsumerTest`)" — but none of that commit's seven files touches `tests/Unit/Hub/RelayConsumerTest.php`; at review time the pin existed only as uncommitted working-tree dirt, the phantom-closeout-in-ledger class this estate's law prohibits. The landed entry stays verbatim per the append-only discipline (the unrated-typo forward-note in `947f7de8` itself is the precedent); THIS commit is the real ship and this entry the ledger truth. What shipped: `test_the_forwarded_family_is_stripped_on_the_chunked_head_leg_too` drives forged `X-Real-IP` + `x-forwarded-*` through a HEAD→BODY→END chunked request to the END-triggered dispatch and asserts the family never survives the `buildRequest()` funnel and the leg keys to the loopback marker — extending the SRV-M16 single-frame pins to the accumulator leg. Mutation teeth, both measured at tip `87616b0f` on scratch worktrees: removing the funnel's forwarded-strip clause reddens exactly the 3-test SRV-M16 family (the two pre-existing single-frame pins + this one; the DTO-leg XFF re-pin stays green by design), and the leg-specific threat the pin guards — a chunk accumulator bypassing the funnel — reddens exactly 1 test, this pin. Suite: `RelayConsumerTest` 102/522 green at tip. Gates: `phpcs-tests.xml` exit 0 and phpstan (tests config) clean on the file; the `--standard=PSR12` before/after delta is the file's own snake_case method-name convention (excluded per the S128 ruleset's measurement, the CI PSR12 leg targets src/ only). No production change, zero new wire codes, src/ census inert — no seed sites added. [review server__srphp] - **End-user error copy now speaks English.** Four validation refusals that ride straight to the browser said `password must be a string` (twice — user create and update), `theme must be a known theme id string` (a blank or typed theme PUT — it now names the valid themes, matching its `Unknown theme` sibling two lines below), and `Refresh token missing subject` (the claim is called a user id by people, not by JWT). The group-cap refusal prose `Maximum group limit reached` became `This server can't create more watch groups right now.` — its dotted `syncplay.group_limit_reached` code is unchanged beside it (the code is wire contract and localized clients read the code; only the copy moved). A new `UserFacingErrorCopyTest` pins the shipped sentences byte-for-byte and forbids the retired phrases in the three audited files; the S431 file-count denominator rotates 2064 to 2065 for the added test file, measured from the phpunit red, not predicted (review server__copywriter, SRV-m42). - **Forward-note (census figures): the SRV-m43 S431 re-pin landed on wrong numbers.** The `d0c948b0` entry below says the declared-writes denominator rotates 1170 to 1174 “measured in a clean tip worktree plus this diff” — the worktree was clean but STALE: it predated the sibling INFO-SERVER-49 commit that stepped writes 1170 to 1171 (and reads 460 to 461) on master mid-lane, and the swap that committed m43 additionally carried the census file off that stale base, briefly reverting the reads pin and its staircase note. The corrected register: writes 1170 → 1171 → 1175, reads 461 untouched (the m43 harness seeds add writes only), re-measured from the phpunit red at the rebased tip `947f7de8` in a clean worktree, and the INFO-SERVER-49 notes restored verbatim. The earlier text stands verbatim as history; this note corrects the reading (review server__dba, SRV-m43). - **Forward-note (wording): the `b1f13dc1` entry says refusing unlinked hub principals would enforce "an unrated linkage policy" — the intended word is "unruled" (as in that commit's own message: no policy of ours rules the unlinked state). The landed text stands verbatim; this note corrects the reading (review server__dba, M18RW-R3 Info).** - **Deferred record (owed by the `FIX-SRV-MAJORS-WAVE3` srv-w4 closeout): the account hold answers its `403` contract on the legs `dd9c848c` mapped (SRV-M17, review server__pm).** Before the cure the typed `PasswordChangeRequiredException` refusal threw on five served legs that mapped none of them — `AuthController::login`'s password leg, `ldapLogin` (swallowed by its generic `RuntimeException` catch into a misleading `503 provider_unavailable`), `WebAuthnController::finishAuthentication`, `QuickConnectController::token` and `ProfilesController::switchProfile` — so the router answered a bare `500`. Each leg now catches the typed exception and answers `403` + `{error, code}` (browser legs redirect `/login?error=`), always WITHOUT minting a session; zero new wire codes (`auth.password_change_required` was already in the vendored contracts registry — the README pin's r1 design-note flip rode this closeout). The refresh leg keeps its pre-existing plain-`InvalidArgumentException` → `401` mapping. The hub-token leg is explicitly NOT in `dd9c848c`'s scope: its typed refusal still answers the router's central `500` interim. Census rotated IN the adding commit from measured red (files 2061→2062, declared-writes 1157→1164 — harness seeds only). - **Deferred record (same closeout): newsletter subject tokens interpolate (SRV-M11, review server__copywriter; shipped `bf8f3a3d`).** `renderSubject()` returned the config string verbatim behind a vacuous self-reassign vestige — an admin writing `{$week_start}` got the literal token in every subject line. The two advertised date tokens now interpolate by plain `str_replace`; deliberately NOT Smarty `string:` evaluation (a deployment config string is not template code — the trust boundary is stated in the docblock). `DEFAULT_SUBJECT` single-sources the fallback; non-string templates keep the prior fallback semantics. +3 tests, red-at-parent measured on the token leg. - **Deferred record (same closeout): the stale-group sweep tears down completely (SRV-M9, review server__asphp; shipped `08266743`).** `cleanupStaleGroups()` performed exactly two unsets: no `unindexGroup()` — `connectionToMember` entries stranded forever (the later socket close cannot recover them once `memberToGroup` is gone) and the blunt unset also clobbered a member's NEWER group mapping; no `removeSnapshot()` — the sole `syncplay_snapshots` DELETE path, so REST rails served ghost rooms and the table grew unboundedly on the never-restarted resident `:8097` worker. The loop now mirrors the sibling teardown paths (identity-checked unindex, leave-empty snapshot removal, operator-visible log line). New `SyncPlayStaleTeardownTest` 2 pins, red-at-parent 2 measured; census files 2062→2063, writes held. - **The login, register, refresh, provider-login, quick-connect, profile-switch and `GET /api/v1/me` responses stop shipping the credential columns of the `users` table (review server__dba, SRV-m43 — the auth-read twin of SRV-M12).** `AuthManager::getUser()` unset only `password_hash`, so every envelope fed by it — the four AuthManager mint legs (register, password login, provider login, refresh) through `buildAuthResponse()`, plus the three controllers that mint or echo it directly (WebAuthn finish, quick-connect token, profile switch) — carried each account's Argon2id reset-token hash and the external-provider payload (which holds provider access/refresh tokens) to the client on every call. The cure is the same single-source helper SRV-M12 shipped: `getUser()` now returns `UserRow::forWire()` at the shared source, and the authentication verify legs are untouched by law (they consume the RAW row via the repository lookups before any mint runs, so `password_verify()` keeps the credential it needs — the anti-over-redaction group-3 pin stays green). Data-minimization decision, documented in the method docblock: account-state columns (`status`, `must_change_password`, `password_reset_expires_at`, …) SURVIVE — they carry no secret entropy and the admin SPA, console client and self-service /me renderers read them; only the secret-bearing trio is stripped. The estate grep behind the change: no consumer — ui, hub, tizen, windows, mobile, console or roku — reads `password_reset_token` or `provider_data` from any response (the console's `AdminUser` docblock already DOCUMENTED they never appear on the admin family; this makes the auth family true to the same word). Ten new legs in `UserRowWireRedactionTest` measured RED at the parent (source-law pin + one per wire surface, envelopes asserted credential-free AND operationally complete), and the SRV-M12 consumer tripwire — a hand-written list of three files that let a fourth bypasser (this very `getUser()` family) slip silently — is replaced by a mechanical `->getUser(` call-site census pinned to an audited four-file set with wire roles documented. The S431 declared-writes denominator rotates 1170 to 1174 with the four sanctioned harness-seed sites in this commit, measured from the phpunit red in a clean tip worktree plus this diff. [server__dba] - **The hub-token hold-guard now keys the RESOLVED server identity — the round-1 cure was vacuous in production and this eliminates it (review server__pm, SRV-M18 RW2).** `1da9f7b1` placed the shared `refuseIfHeld()` guard correctly but keyed it on `$claims->userId` — the HUB-side UUID. The `must_change_password` flag lives on this server's own `users.id` rows, a key space hub UUIDs can never enter (`UserRepository` generates local ids; the hub↔server mapping is `user_identities`, which is exactly why `RelayIdentityResolver`/`AccountLinkController` exist), so the lookup always missed, the flag read false, and a HELD LINKED account still received its `server_session_token`. The shipped pin hid this by mocking the answer-true for the foreign key. Now: the exchange first resolves hub principal → local account through the same single-source `RelayIdentityResolver` the relay path uses, and the guard consults the RESOLVED id. UNLINKED hub principals are an explicitly ratified refuse-open arm: no local row means no hold subject at all — the resolver lineage's own S301 law rules the no-row state "the honest state, not a hole", refusing would enforce an unrated linkage policy needing a frozen wire code, and the minted token's hub-UUID subject is inert against every wired gate's users-row liveness law (linking itself requires a server-authenticated session a held account cannot obtain). The mint's wire shape is untouched — subject, claims and responses are byte-identical; only the guard's key moved. The fixtures now separate the id spaces for real (distinct hub/server UUIDs, the flag row answerable only under the server id through a recording `UserRepository` connection), so stripping the resolution leg reddens the held leg, the consulted-key leg, and the unlinked never-consults-users leg. The OAuth callback legs were re-confirmed to key the LOCAL `findOrCreateByExternalId` id (unchanged). Collateral [Minor]: the blank separator `1da9f7b1`'s rebase scar deleted between two pre-existing entries (ipd architecture-tree / collection-route cites) is restored — history whitespace was collision damage, not a rewrite, and append-only means the separator too. - **Guarded egress refuses redirects — the notification family's SSRF law now reaches hop two (review server__secaudit U1, against the entry below).** The plugin send sites' HTTP contexts omitted `follow_location`, so PHP's wrapper followed up to 20 hops of whatever a legitimate, guard-passing endpoint answered: a configured-public URL answering `302 Location: http://169.254.169.254/…` smuggled bytes past a check that only ever saw hop one. The family's transport options now flow through `AbstractNotificationPlugin::buildEgressHttpOptions()` — the single source pinning `follow_location=0` + `max_redirects=0` (with the shared timeout/ignore-error shape a site cannot override) — and `egressSucceeded()` refuses any 3xx answer with one logged warning naming no URL (`<Provider> notification refused: HTTP redirect not followed`) and a false `send()`. Same seam, one latent repair: the seven per-site `getLastResponseCode()` copies read `global $http_response_header` from a helper scope — the wrapper populates it only in the CALLER's locals — so every send reported failure even on a genuine 200 (unobservable so far: the family has no dispatcher wired yet, see the README note); one `terminalResponseCode()` fed by the call site replaces them. Pinned by `NotificationPluginEgressRedirectTest` against a live loopback `php -S` fixture: the five configured-URL plugins knock on a path that answers `302 → /secret`, the second path must NEVER be requested (zero bytes out to the target), the refusal line must be logged with neither URL named, and a plain 200 leg now returns true — all seven legs measured RED at the parent. The fixed-host plugins (Telegram, Pushover) cannot be dialled at the fixture; their builder carries the same law, pinned through the single source. **Forward-corrections to the entry below** (verbatim history stands): its "zero bytes out" held for hop one only — the redirect class the review names was open until this entry; "whose moment-of-use check also closes the DNS-rebind class" overstated the mechanism — the guard validates its resolution at CHECK time while the transport re-resolves independently at connect time, and the honest bounded residual (one connection, no redirect hop with this fix) is now stated in the class-level egress law and the two fixed-host sites' comments. README likewise: the refusal-log redaction is the plugin family's law, not estate-wide — the webhook surfaces' `WebhookService` (log line and delivery-record error), `WebhookDispatcher` (delivery-log row) and `MqttPlugin`'s inline guard still carry the guard's full-URL message — extending the redaction door there is registered, not shipped. The S431 file census staircase continues 2063 to 2064 with its pin file (measured from the rebased-tip red — SRV-M17 and SRV-M9 stepped 2061→2062 and 2062→2063 while this fix was in flight); the declared-writes denominator holds at the rebased tip's 1164, this commit adding zero write sites. [server__secaudit] - **Every notification plugin re-validates its egress URL at the moment of use — the webhook path's SSRF law now covers the whole outbound family (review server__secaudit).** The SRV-M20 cure guarded the webhook surfaces, but the sibling notification plugins (`src/Webhooks/Plugins/`) posted events to whatever URL their configuration named: Slack, Discord, Apprise and ntfy read a raw `webhook_url`/`url`/`server` and called `file_get_contents()` with no re-check, while MqttPlugin already applied the guard — the divergence this finding named. `AbstractNotificationPlugin` now owns the family's single-source predicate `guardEgressUrl()`: the fully-composed destination passes `SsrfGuard::assertPublicUrl()` before any transport, and a refusal is one logged warning (`<Provider> notification blocked by SSRF guard`, reason included, credential-bearing path redacted) plus `send()` returning false with zero bytes out — the webhook surfaces' refusal shape. All six unguarded plugins gained the one-line adapter (Slack, Discord, ntfy, Apprise, and the fixed-host Telegram and Pushover legs, whose moment-of-use check also closes the DNS-rebind class); MqttPlugin's proven inline guard stays untouched. Pinned by `NotificationPluginSsrfParityTest`: http/https are swapped for a spy stream wrapper, so every leg measures the transport itself — 11 cases measured RED at the parent (four poisoned configured-URL literals, six resolver-rebind hosts including both fixed-host plugins, and the logged-refusal-without-URL-shape leg), six public-destination legs green on both sides proving the guard is not a kill switch. The S431 file census staircase continues 2060 to 2061 with its pin file; the declared-writes denominator was re-measured unchanged (1157) at this tip in a clean worktree. [server__secaudit] - **The admin user endpoints stop serializing the credential columns of the `users` table — `GET /api/v1/admin/users` (every cohort) and `GET /api/v1/admin/users/{id}` no longer ship `password_hash`, `password_reset_token` or `provider_data` over HTTP (review server__dba, SRV-M12).** Both listing readers were bare unpaginated `SELECT *` and the controller handed the rows to `json()` untouched, so every admin poll of the user list (or the signup queue) carried each account's Argon2id password hash, the Argon2id hash of its live one-time reset selector and the external provider payload (which holds provider access/refresh tokens) into the browser's memory, any proxy or debug tool, and any future response logger. The root cause was the per-caller convention: `AuthManager::getUser()` remembered to unset `password_hash`, `UserListCommand` kept its own private three-column list, and the admin list forgot entirely. The estate now has ONE definition of the set — `UserRow::forWire()` behind `UserRow::SECRET_COLUMNS` (the same trio `UserListCommand` had independently ratified) — which `UserRepository::findAll()` and `UserRepository::listByStatus()` apply at their source, so a credential column can no longer reach an admin-listing caller by forgetting, and `AdminUserController::get()` (which legitimately reads the raw `findById()` row the authentication paths need) routes that one row through the same helper. `UserListCommand` now defers to the shared helper (its duplicate list retired, defensive call kept); the login lookups (`findById`/`findByUsername`/`findByEmail`) deliberately stay RAW — `password_verify()` needs the hash, and a pin asserts that so the cure can never regress into breaking login. Operational state (`status`, `must_change_password`, `password_reset_expires_at`, `tokens_not_valid_after`, …) still ships: the vendored admin SPA and the console client read those columns and none of them carries secret entropy. Pinned by `UserRowWireRedactionTest` (nine cases, eight red at parent): the hydration law on both readers, the wire shape of all three admin responses (no credential key survives, no `argon2id`/token literal appears in the body, the rendered columns survive), helper purity and key order, the raw-auth guard, and a `token_get_all` tripwire asserting the secret set is declared in exactly one src/ file (red at parent, where the console command carried a second copy) plus call-site pins that all three consumers go through `forWire()`. The S431 census staircases continue 2059 to 2060 with the pin file and 1156 to 1157 with its one sanctioned `$request->query` seed. [server__dba] - **Webhook deliveries re-validate the stored URL through the SSRF guard on every attempt — a subscription can no longer POST to a target the guard would refuse (review server__secaudit).** The unsigned subscription subsystem took whatever URL it was handed and posted events to it at both dispatch sites, so a row mutated out-of-band (or written by a future non-guarded path) could send signed-shape payloads to link-local, loopback or other internal endpoints. `WebhookService::createSubscription()` now runs `SsrfGuard::assertPublicUrl()` at the service boundary (defense in depth behind the admin controller's existing guard), and `processDelivery` / `retryDelivery` re-check the URL on the row they are about to POST: a refusal is recorded as a failed delivery (retryable shape, logged, response body names the guard) and zero bytes leave the process. Pinned by `WebhookServiceSsrfTest` (six network-free cases: create-time refusals, public-literal pass-through, mutated-row and poisoned-retry delivery legs). The S431 file census staircase continues 2058 to 2059 with its pin file. [server__secaudit] - **Smart collections stop lying — a bogus `smart_playlist_id` is refused 400 at create/update, and a dangling edge answers refresh with 409 instead of a 200 that refreshed nothing (review server__pm).** Create and PUT accepted any `smart_playlist_id` verbatim (migration 122 deliberately keeps the edge free of constraint), and POST /collections/{id}/refresh answered `{message: 'Smart collection refreshed'}` while the manager silently returned on the missing playlist. `CollectionManager` gains `smartPlaylistExists()` (the write-time oracle) and `refreshSmartCollection()` now reports: null when the edge dangles or the collection is not smart, else an `items_added`/`items_removed` diff the controller ships with its 200. The controller refuses unknown playlist ids at write time, answers 409 `Smart collection references a smart playlist that does not exist` on a dangling refresh, and its docblock/README/openapi (new 409 leg) state the real contract. The owner-gate refresh arms stub the healthy diff (the controller reads the new return), and the S431 file census rotated 2057 to 2058 with its pin file (the SRV-M20 sibling continues the staircase). [server__pm] - **The auth middlewares' wire-shape docblocks told the truth about a dead world: `AdminMiddleware` pointed at "the SSR page-route branch in `public/index.php`" as a co-consumer (that front controller was deleted by S171 and its removal is guard-pinned), advertised its 401/403 envelopes as bare JSON `{error}` while the code emits `{error, code}` (`auth.required` / `auth.not_admin`), and `AuthMiddleware` still named `public/index.php` as one of "both HTTP entry points" that populate `$request->userId` (review server__fedev, SRV-m21).** Envelope-shape docblocks are the browser contract in this repo. They now state the emitted `{error, code}` pair, name the sole live entry point (the served Workerman `HttpHandler` running the `RequestAuthenticator` seam), and describe `checkAccess()`'s actual co-consumers — the Admin-surface controllers' in-handler guards — instead of a file that no longer exists. - **The copy-pasteable version-pin example pinned a tag that does not exist: `--branch v0.2.0` is not a release — the repo's tags start at v1.0.0, and both the README section and the installer's own `--help` carried the dead ref (review server__newcomer, SRV-m26).** A deliberate pinner following the docs hit a checkout failure mid-update. Both examples now use a live tag (`v1.2.3` = the current VERSION) and each site names where published tags actually live (Releases / `git tag -l`), so the example ages honestly rather than pointing at a fictional era. - **The bare-metal exposure note said `install.sh` opens "only `:8096`" in ufw — it opens more, and an operator auditing firewall exposure from that sentence would mis-size the surface (review server__docmaint, SRV-m16).** Measured: the direct-bind path adds the HTTP port AND DLNA `1900/udp` (`scripts/install.sh` ufw block); the managed-HAProxy path adds `80/tcp`, `443/tcp` and `1900/udp`. The note's warning half — `:8097` is never opened, pin or proxy it — stayed true and now reads: opens the HTTP port (plus `1900/udp`, and `80`/`443` in the managed-proxy path), never `:8097`. - **The environment census no longer promises a row that does not exist: the anidb cross-reference said `PHLIX_ANIDB_CACHE_DIR` "is itself a row below", and the census has no such row (review server__docmaint, SRV-m15).** The clause's point — plugin knobs resolve inside this process — is true; only the pointer was fabricated. The parenthetical now states the actual relationship: plugin code reads the variable, no in-repo source does, so it deliberately has no census row. Readers auditing against this list follow a pointer that lands. - **Sign-in around the admin force-change wall is closed everywhere: the OIDC and GitHub browser callbacks and the hub-token exchange now honor the `must_change_password` hold, and so does OPDS HTTP-Basic (SRV-M18 rework, review server__pm).** The d8c834d5 cure gated `AuthManager`'s own legs and documented the law as universal, but the two plugin OAuth callbacks minted full cookie sessions by calling the JWT handler directly and `POST /api/v1/auth/hub-token` minted a server bearer the same way — a held account could still sign in by clicking "Sign in with GitHub/OIDC". The refusal law is now single-sourced in `PasswordChangeRequiredException::refuseIfHeld()` and every mint site calls it: the seam's inline check was folded into the guard (its shipped audit shape and tests unchanged), the callbacks refuse before `createTokensForUser()` and answer their own shipped redirect voice — `?error=password_change_required`, zero cookies — and the hub-token leg refuses before the exchange token exists (typed 500 interim until SRV-M17 maps it, like the passkey/quick-connect legs). Scope decision on the reviewer's OPDS [Minor]: EXTENDED, not scoped — a compromised password can now neither mint nor keep streaming; `verifyCredentials()` answers a held account with its contract's silent null, charging the per-IP brute-force budget like any other refusal (disclosed). New red-measured held-account legs in `OidcCallbackControllerTest` / `GithubCallbackControllerTest` / `HubTokenControllerTest` / `AuthManagerVerifyCredentialsTest` (all four verified RED at 3e3e47a8), plus `MintSeamLawTripwireTest`: a token-walk over `src/` pinning the exact set of token-minting files, that each gated minter calls the guard, and that the one inert carve-out (`SyncPlayAuthMiddleware::createToken`, zero callers) stays unwired until gated. README rows, the exception/seam/UserRepository/AdminUserController docblocks and the latch-test header now read TRUE at tip: with every minter behind the hold, no route can mint a session around it. `HubTokenController` gains a required `UserRepository` constructor param (all construction sites — container binding, legacy no-container fallback, tests — updated in lockstep). Zero new wire codes, zero migrations; every shipped envelope byte-identical. - **README Architecture tree now maps all 24 top-level `src/` subsystems — machine-pinned both ways.** The tree stopped at Server/Session/Media/Auth/ Common, omitting 19 real subsystems (Access, Admin, AirPlay, Casting, Chromecast, Collections, Config, Console, Discovery, Dlna, Hub, LiveTv, Network, Playlists, Plugins, Roku, Stats, Theming, Webhooks) — a reader auditing features against the map concluded the casting, hub-relay, webhook, DLNA and theming stacks did not exist. `ArchitectureTreeCoversSrcSubsystemsTest` now reddens if any `src/` directory leaves the tree OR the tree names one that never lands; it validates directories only, deliberately — file-level README detail is exactly what rotted before. The empty `.gitkeep` placeholder `src/Session/Playback/` stays unlisted: the map documents territory, not intentions. [review server__ipd] - **README collection-route cites re-anchored to the shipped lines — `CollectionRoutesReadmeCitesTest` was RED (all 9 datasets) at master.** `8bb435cf` recorded the SRV-M2 cite-table rotation in its message, but its README hunk never made it into the commit: the `selfServerEntry()` closure extraction moved the collection registrations +3 lines (`:2249` era → `:2252` era) while the table kept citing the pre-move lines. The pin test did its job — it reddens exactly this drift — and the table now lands on the exact `$r->…` lines again. Verbatim-history note: the bullet inside the `8bb435cf` entry above stays as written; this entry is its correction. [review xrepo__server-ui] - **The admin MetricsPage server-detail card renders again — the server now answers `GET /api/v1/servers/{id}` for its own id (review xrepo__server-ui).** The shared admin surface fetched the selected server's detail row, but the route was never registered server-side, so every lookup 404ed and the card stayed empty on server hosts. The server publishes its truthful self-entry — the same identity the list rail hands out (enrollment id → config id → process-stable fallback), plus the build `version` — and 404s every other id rather than impersonating another fleet member. Fleet metric fields stay omitted (no honest request-time source; the client degrades them to zero by design). Auth posture matches the list (`AuthMiddleware` only, anonymous 401 with zero disclosure). S64 composed-route inventory, S239 wire-path manifest, S65 spec currency (350 to 351 templates) and the SRV-M2 README cite table (+39-line re-anchor after the closure extraction) all rotated in the same commit. [xrepo__server-ui] - **Rail cards can create playlists again — the continue-watching and Next-Up rails now carry a real `library_id` (review xrepo__server-ui).** The SPA's 'Add to playlist → Create new' leg POSTs the card row's `library_id`, and `CollectionController::create()` answers 400 `Library ID is required` on a non-string. c42e166a restored the key for GRID rows (their queries are `SELECT *`; the shape() whitelist was the only thing dropping it), but both HOME rails project narrow column lists that never asked for `mi.library_id`, and each builds its `shape()` input from an explicit key set that omitted it too — so every rail card shipped `library_id: null` and playlist creation from those cards failed for every user. The two projections now carry `mi.library_id` and the two `$item` builders thread it into `MediaItemShaper::shape()`, pinned at both legs (SQL projection + wire value) by new tests. Zero schema, zero new routes, zero contract moves: the key was already in the shape()'s whitelisted 27 since c42e166a. - **Media browse now honors the FilterBar's type chips and max-rating cap, and a member's watch history answers on both wire paths (review xrepo__server-ui).** Two server Majors landed mid-window without lines here — the CHANGELOG file belonged to a concurrent lane that wave (disclosed in the xrepo closeout register); this is the collected entry, one per cure: - *Browse filters (03bf5c2b).* `GET /api/v1/media` silently ignored the SPA's `types[]` chips and `maxRating`: `ItemRepository` now declares the 13-member `TYPES` vocabulary (matching the contracts `MediaType` union) and `buildFilters()` gained the type-IN arm plus the `(rating_score IS NULL OR rating_score <= ?)` cap — unrated items survive a ceiling, the deliberate asymmetry against the existing floor, documented in place. `WebPortalRouter::extractMediaQueryParams()` parses the boundary vocabulary-filtered, deduped, degrade-absent so unknown members can never reach SQL. Eleven new tests (`ItemRepositoryTest` +6, `WebPortalRouterMediaTest` +5), red-measured pre-cure. - *Watch history on both wire paths (a86e0999).* The SPA's WatchHistoryPage fetches `GET /api/v1/users/me/history` and its `GET /api/v1/me/history` alias twin, but only the DELETE legs existed — the page 404ed for every member. Both GETs now serve from `WebPortalRouter`: 401 / 404-no-profile / 503-unwired idioms verbatim, S80 profile resolution, one batched `findByIds` (M-SERVER-16 law), the rating gate applied to RAW rows pre-shape, and the `{id, media, progress 0..1 (completed→1.0), updated_at}` envelope; both openapi ops added, and the S65 route-template pin 349→350 with the wire-path guard manifest +2 rows rotated in the same commit from measured reds. Eight new `WebPortalRouterWatchHistoryGetTest` cases. - **CORS preflight now advertises every header the server actually reads and the SPA actually sends (SRV-M16, review server__fedev).** `ALLOW_HEADERS` listed only `Authorization, Content-Type, X-Device-Id` while `src/` reads `X-Phlix-Device-Type`, `X-Phlix-Client-Capabilities`, `X-Session-ID` and `X-Request-Id`, and the vendored `@phlix/ui` bundle sends `X-Phlix-Device-ID`/`-Type`/`-Name` + `X-Phlix-Session-ID` on every API request — so any credentialed cross-origin browser client that preflighted one of those names was refused on the actual request, silently degrading those server features to same-origin-only. The list gained the four server-read names and the four SPA-contract spellings (matching is case-insensitive on both planes); proxy infrastructure headers (`X-Forwarded-*`, `X-Real-IP`, `Host`) stay deliberately out — a browser must never be allowed to forge them. Two tripwires in `CorsManagerTest`: one scans `src/` live for client-reachable `X-*` read sites (both `getHeader()` literals and `$_SERVER['HTTP_*']`) and asserts each is advertised, the other pins the SPA contract family literally (node_modules is untracked, so a bundle rename rotates that pin deliberately). Both refusal pins measured RED at the pre-cure constant; the 11 existing CORS tests pass UNMODIFIED. No origin-matching, credentials or Vary behavior changed. [review server__fedev] - **The Contributing list's raw-`phpunit` counsel now names its workflow — it was true of `phpunit.yml` but read as a claim about all of CI.** The entry below cured the absolute overclaim ("CI never runs a single `phpunit` invocation") into "its only raw `phpunit` invocations are the serial E2E tail and a Server-specific slice" — one magnitude down, the same class: as a CI-wide sentence it is false. The repo's own re-derived census (`scripts/skipped-test-names.sh`, exactness guard-pinned by `SkippedTestNameReportingTest::test_the_documented_invocation_sites_are_still_the_real_ones`) counts FIVE raw sites CI-wide — `phpunit.yml` twice, `syncplay-e2e.yml` twice, and `scripts/assertion-escape-audit.php` once. The bullet now reads "the PHPUnit workflow's only raw `phpunit` invocations…", which is the truth `phpunit.yml`'s own invocation-census comment already pins of itself ("exactly TWICE in this file"). The entry below's headline "CI runs raw `phpunit` exactly twice" is verbatim history and stays; this entry is its correction. [review server__ipd] - **Three README claims about running tests were still not true.** The Contributing step said CI "never runs a single `phpunit` invocation" — CI runs raw `phpunit` exactly twice (the serial `E2E,Serialized` tail and the Server-specific slice; `phpunit.yml`'s own invocation-census comment pins the count), so the sentence now scopes itself to the two fast suites driven through the parallel seam. The `Running tests` section opened its fence with "# Run all tests / ./vendor/bin/phpunit" while its own law four paragraphs down commands the seam for a full pass — the fence now leads with the seam commands for the full pass and keeps serial commands scoped to a single file or small slice, and the real-database example gained a sentence siting its single-schema direct form against CI's eight-clone seam shape. And the `--update` paragraph named the docker-only alias `PHLIX_SECRET_KEY` as the preserved secret where the native guarantee is about `JWT_SECRET` — the same survivor class the install-headline cure closed one commit up. [review server__writer, server__ipd] - **Transcode file serving followed symlinked files out of the job dir.** The realpath containment in `TranscodeFileServer::serveJobFile()` validated the job DIRECTORY only, while `is_file()` and Workerman's file sender follow symlinks — a symlinked segment planted inside a legitimate (valid-UUID, in-base) job dir streamed out-of-tree bytes, which the 2026-11-03 cure's own docblock had promised could not happen. Serving now additionally requires the file's realpath to BE the resolved job dir or live under it (a dangling link fails the same arm), and a refused file answers the same byte-identical 404 `Segment not found` every other absent/outside file already gets — no new oracle, no new wire code — with the escape detail logged server-side. The pin plants a real symlink in a mirrored temp job dir (no privileged ops) and refuses it, then proves a real file in the same dir still streams, so the arm can not silently become a deny-all. [review server__secaudit] - **The install headline named the alias as the secret.** The one-line install bullet listed "generated `PHLIX_SECRET_KEY`", but the boot-critical key the daemon refuses to start without is `JWT_SECRET` (`AuthServicesProvider::assertSecretConfigured()` reads only that name), and the README's own env census documents `PHLIX_SECRET_KEY` as the docker-entrypoint alias the PHP runtime never reads. The headline now names the real key; the installer still writes both (the alias row is container parity), so nothing else moved. [review server__ipd] - **Contributing step 2 prescribed the serial full-suite run the README's own tests section forbids.** "Ensure all tests pass (`./vendor/bin/phpunit`)" contradicted "CI does not run a single `phpunit` invocation, and neither should you for a full local pass"; a contributor following the list literally got a slow, contention-prone serial run that is not what CI gates. The step now names the parallel seam for a full pass and keeps the serial command scoped to single files and small slices, matching the README's "Running tests" law. [review server__ipd] - **Dead `@see plans/...` pointers in the alias-compat shim docblock.** The two provenance references named `plans/expansion/b.1-shared-design.md` and `plans/expansion/b.3-shared-consume.md`, but no `plans/` directory exists in the tree — the plan set was retired into phlix-docs (`77e681fd`). They now point at the live carriers of the same content: the phlix-docs plugin SDK page (the canonical move table) and the in-repo interface bridge file. [review server__ipd] - **Stale autowire cite in the README smart-collections bullet.** The prose pointed at `MediaServicesProvider.php:899` for the `SmartPlaylistRefreshSubscriber::autowire()` registration; the line moved to `:900` under an earlier insertion. Re-anchored to the measured line; the wiring itself is unchanged. [review server__docmaint] - **Stale openapi line cite in the README collections note.** The prose pointed at `openapi.yaml :5738` for the first `/api/v1/collections*` path; the block actually starts at `:5728` (a 10-line drift from an earlier spec edit). The cite now names the measured line; the cited path set is unchanged. [review server__docmaint] - **Double period at the end of a README sentence.** The Library-management bullet closed with two periods after the phlix-docs server-settings link (`..` where house typography wants `.`); the links and the sentence are otherwise untouched. [review server__writer] - **The containment test's docblock claimed an estate-wide scope the review never established.** `SyncPlayDispatchErrorContainmentTest` called the SRV-M21 catch "the estate's last raw-leak arm" — server__secnet's finding scoped its law to the SyncPlay WebSocket dispatch; the docblock was the one surface where the wording inflated to the estate. The sentence now reads at its verified scope; the containment law and both tests are untouched (2/6 green on the new bytes, phpcs both standards clean). [review server__secnet] - **48d8bb8f's workflow cure over-claimed, and RELEASE_PROCESS.md survived the same sweep.** README asserted there is "no feature-branch/squash-merge ceremony in this repository's history" — false: master carries 26 merge commits (newest PR #800, 2026-09-25). The claim now reads the bounded truth (zero merges in the 300+ commits since). Same prose class, same repo, missed by that sweep: RELEASE_PROCESS.md still prescribed a release branch ("### 1. Create release branch"), a pull request ("### 4. Create pull request", "After PR merge:") and a hotfix branch/`merge --no-ff` ceremony the repo never ran — releases actually land as direct master commits (v1.2.3 = `c17bb9ec`) with `scripts/release.sh` pushing master, then the tag. All three steps now state the practiced flow with in-place correction notes. Docs only; the pinned "GitHub Actions will:" trigger/mirrors section is untouched (`ReleaseWorkflowTest` green on the new bytes). [review server__writer] - **The installer's own `--help` was SRV-M5's last "(optional)" holdout.** The newcomer finding's headline defect was every doc calling the TMDB key optional; the README flag row got the "optional for the installer, required for provider metadata" reword, but `scripts/install.sh --help` — the counsel a literal walker reads DURING the install the quickstart defers to — kept the bare qualifier. The usage row now mirrors the cured wording, wrapped in the table's existing continuation style. Help text only: `bash -n` clean; the `--help` render diffs to exactly this row; the three installer-text test classes read none of it (grep-verified). [review server__newcomer] - **The [Unreleased] CHANGELOG cites that SRV-M2 explicitly named were left stale.** The server__docmaint recommendation reached past the ten README rows: the M28 entry inside the editable `## [Unreleased]` still cited `Application.php:2194-2218`, its `POST /api/v1/playlists` alias at `:2210`, and the ownership docblock at `:2162-2189` — the same drifted block the README rows were re-pinned from. The lane's "history stays verbatim" justification applies to DATED sections only; house discipline makes `[Unreleased]` editable, and the new tripwire covers README rows only, so these cites would have aged silently. Re-anchored to the measured tip lines (`:2209-2229`, alias `:2221`, docblock `:2173-2200`); Application.php itself untouched. [review server__docmaint] - **A crashing SyncPlay message handler no longer mails its raw exception text to the connecting peer.** The dispatch catch-all in `SyncPlayManager` answered `HANDLER_ERROR` with `$e->getMessage()` verbatim — the exact leak the sibling `MessageHandler::reportHandlerFailure()` layer exists to prevent (R17-03: the wire never carries exception text; these strings class-carry SQL fragments, file paths, and other users' data). The peer now receives the same fixed generic sentence the guarded layer standardizes on, while the real cause (class + message) stays in the server-side error log. A canary-shaped containment pin reddens if any exception text resurfaces on the wire. [review server__secnet SRV-M21] - **The contributor docs prescribed a git workflow the repo does not run.** "Git workflow" taught feature-branch → PR → squash-merge and "Contributing" told newcomers to submit pull requests, while 200+ commits of history are all direct-to-master conventional commits with zero merge commits. Both sections now state the real practice — maintainers commit straight to `master` with `type(scope):` subjects; external contributions arrive as PRs — with the two sections cross-linked. [review server__writer SRV-M7] - **The one credential the headline feature cannot work without was buried.** Every doc called the TMDB key "optional" (installer flag) or never mentioned it (quickstart step 3, the Docker "minimum" note), so a literal walker ended up with a named-but-posterless library. The quickstart now names the key and both set-paths; the installer flag and the env-census minimum say "optional for the installer, required for provider metadata". [review server__newcomer SRV-M5] - **All ten README collection-route line cites had drifted ~13 lines off their registrations.** The endpoints table promises "each registration's exact line is cited"; a single insertion above `loadCollectionRoutes()` had shifted the block with nothing re-anchoring it. Every cite is re-pinned to the measured line and `CollectionRoutesReadmeCitesTest` reddens on the next silent drift. [review server__docmaint SRV-M2] - **The documented default Helm install could never boot: DB_HOST pointed at a MySQL Service this chart cannot deploy.** `values.yaml` shipped `mysql.enabled: true` with a templated `config.database_host` defaulting to `<release>-mysql` — but `Chart.yaml` declares no `dependencies:`, `charts/` holds only a `.gitkeep`, and no template creates that Service, so the README quick-start produced a pod failing every migration under the image's baked `PHLIX_MIGRATIONS_STRICT=1` entrypoint gate → guaranteed CrashLoopBackOff. The cure removes the fiction end-to-end: `mysql.enabled` now defaults `false` (the block is documented as an inert hook until a real subchart is vendored), `config.database_host` loses its unfulfillable default and is wrapped in `required` in `templates/deployment.yaml`, so the failure lands at `helm install` with an actionable message instead of as a dead pod; the `secret.yaml` M29 comment and `values.example.yaml` tell the same truth; `k8s/README.md`'s "The chart can include a MySQL subchart" section is rewritten as "This chart deploys no database" and the quick-start now passes `config.database_host` (the form the hub quick-start already used). Verified with real helm 3.16.4 (containerized — the host ships no helm binary): `helm lint` clean; render with a real host emits `value: "mysql.example.internal"`; render WITHOUT `database_host` fails the install with the required-guard message (both legs executed). [review server__devops 2026-11-03, Critical] - **The newsletter template interpolated `poster_url` into its `src` attribute unescaped.** Poster URLs arrive via `metadata_json` (provider content); a quote-bearing value closed the attribute and smuggled event handlers into every recipient's client. The slot now carries the same `|escape:'html'` the name/alt slots already used; `NewsletterGrammarTruthTest` pins both the escaped-payload and well-formed-passthrough arms. [review server__secaudit SRV-M6] (Provenance corrected forward at review: SRV-M6 is server__secaudit's poster_url finding per FIX-QUEUE.md — server__copywriter carries no poster/escape finding anywhere. The cure commit's subject is history and stands as-landed; this entry's stamp and the `NewsletterGrammarTruthTest` docblock were the misattributed editable surfaces and now read server__secaudit.) - **The newsletter HTML leg mailed "1 new items added to the library this week".** The plain-text leg carries the 0/1 grammar guard (and the HTML play-count line always did — "1 play"); the new-items label was the one site the earlier sweep left behind. The template now declines a one-item week as "new item added"; `NewsletterGrammarTruthTest` pins both arms at the committed bytes. [review server__copywriter SRV-M1] - **Any authenticated member could read arbitrary files on the host through the HLS/DASH segment routes: the `job_id` path parameter was interpolated into the served filesystem path unvalidated.** `{job_id}` matches the raw percent-encoded path but is rawurldecoded once at the router boundary (S435), so `GET /hls/%2e%2e%2f%2e%2e%2fetc%2fpasswd/...` arrived at the handlers as a literal `../../etc/passwd` segment; the entry check was only `!== ''`, the filename gate never looked at the id, and `HlsStreamer::getJobDirectory()` / the DASH twin interpolated the value verbatim into the served path before `is_file()` + {@see \Phlix\Server\Http\Response::withFile()}. Reading the JWT/signed-url secrets off disk meant full authentication forgery. The cure installs the generator contract as the boundary law: transcode job ids are minted exclusively by `Uuid::v4()` (the historical generator was shape-identical), so both controllers now refuse any id that is not a canonical lowercase v4 UUID through a new `isSafeJobId()` arm, reusing each route's existing 400 envelope byte-identically — no new wire codes. Same gate on the JSON pointer arms (`getPlaylist`/`getManifest`). Defense in depth: `serveJobFile()` gained an optional trusted-base parameter whose realpath containment answers the ordinary missing-file 404 for anything resolving outside the segment tree (planted symlinks included), and both streamers' `getJobDirectory()` now fail-loud on non-UUID ids so future internal callers cannot silently re-inherit the hole. `Uuid` gained `isV4()` beside the mint it validates. Regression evidence: traversal-shaped ids (bare `..`, decoded `../../`, the percent form, uppercase/no-hyphen/near-miss UUIDs) refuse with the exact envelopes in both controller suites; a real-Router wire test proves the decoded arrival shape (spy leg) and the gate's 400 (production leg); a symlinked job dir outside the base streams nothing. Measured red at the parent convicted 24 of the new/reworked cases, zero collateral. Fixture job ids in the touched suites became canonical UUIDs — the old `job-N` literals encoded the very laxness the cure removes. [review server__secaudit 2026-11-03, Critical] - **The pre-release checklist demanded a coverage bar the repo cannot meet.** `RELEASE_PROCESS.md` carried "Coverage >= 80%" while the CI floor is `MIN_COVERAGE=40` and measured coverage sits at the 65%-era AGENTS.md records — a false-by-construction checkbox that teaches operators to skip checklist lines. The item now tracks the live gate. [review server__writer SRV-M8] - **Accounts created or reset through the admin API could never log in: the controller hashed the password and the repository hashed it again.** `AdminUserController::create()` and `update()` pre-hashed with their own private `hashPassword()` helper before handing the value to `UserRepository::create()`/`update()` — which run `password_hash()` themselves (the documented repository contract every other caller follows). The stored value was `argon2(argon2(plaintext))`, so `UserRepository::verifyPassword()` failed permanently for every admin-made account and every admin-side password reset; recovery required direct SQL. The cure passes the plaintext through, deletes the dead private helper, and flips the unit test that had encoded the wrong contract (it asserted `password_verify()` on the captured mock argument — the bug pinned as law). A new real-MySQL integration proof closes the loop end-to-end: `AdminPasswordHandoffRealDbTest` runs admin create → login and admin reset → login with the same credentials over a throwaway `phlix_admpw_*` database, pinning that the at-rest hash verifies the plaintext exactly once and that the superseded password stops authenticating. The reset-token helper's `password_hash()` use is a different, correct contract (the store expects pre-hashed tokens) and stays. Census denominators re-pinned from their measured reds in the same commit (adopters 80→81, writes 1123→1124, PHP files 2047→2048). [review server__srphp 2026-11-03, Critical] - **The user-marker create/delete API could never serve any request: both live handlers violated the router calling convention and every call 500'd.** `MediaMarkerController::createMarker(string $id)` and `deleteMarker(string $id, string $markerId)` were registered on rails the router dispatches as `$handler($request, $params)` — under `declare(strict_types=1)` a `Request` cannot bind `string $id`, so `Router::callHandler()` threw TypeError and HttpHandler's Throwable catch answered 500 for every request. A second, independent bug in the same handler: the JSON body was read with `file_get_contents('php://input')` — a CGI/FPM-era idiom; on this Workerman CLI SAPI the body lives on the Request, so even a correctly-shaped handler would have parsed an empty string and answered its own 400. The cure takes `id`/`markerId` from the route params and the body from `$request->rawBody` (the `Invalid JSON body` 400 envelope stays byte-identical), the deliberately unregistered `getMarkers` sibling was reshaped to the convention with them, and the ownership tests now call through the router's own `(Request, array)` shape (they had been calling with bare strings — the exact blind spot). The bug class gets a permanent whole-table guard: `ApplicationRouterWirePathGuardTest::testEveryRegisteredHandlerAcceptsTheRouterCallingConvention()` reflects EVERY registered handler pair (all 353 rails, closures included) and refuses any declared parameter that cannot bind what `callHandler` passes — measured red at the parent naming exactly the two marker rails, zero collateral. `RequestDynamicPropertyCensusExecutableTest` denominators re-pinned from their measured reds in the same commit (reads 457→458: the src `rawBody` read; writes 1119→1123: the four test harness seeds). [review server__srphp 2026-11-03, Critical] - **Newsletter queue collapses no longer pick the row that loses the user's delivery.** `migrations/125_newsletter_queue_user_week_unique.sql` deduped `(user_id, week_start)` groups by keeping the EARLIEST row: a pending retry queued behind an earliest-`failed` row was deleted — and because `NewsletterSender::processQueue()` treats `failed` as terminal, that pending row was the only retry vector for the week, so the user was silently never mailed — while a later `sent` row deleted behind an earliest-`failed` row erased the proof of delivery and under-counted the queue stats. The survivor rule is amended IN PLACE to status precedence `sent` > `pending` > `failed` (then `created_at`, then `id`), the in-file docblock's "the survivor carries the group's full status/attempts history" sentence is rewritten to truth (each row carries only its own), and `tests/Integration/Admin/NewsletterQueueDedupMigration125RealDbTest.php` executes the survivor choice on real MySQL — its four precedence arms red at the keep-earliest parent, green on the amended file. Disclosure for already-migrated databases: the amended file re-applies as a zero-row no-op (the UNIQUE forbids duplicates), and rows the old rule deleted are NOT recoverable from this migration — some users may have missed one newsletter week with no trace in the queue; post-cure installs keep the pending-retry survivor, so an admin re-fire of a week whose retry row still stands delivers again. Major-1 of the 2026-11-04 campaign-diff review. - **Deferred #54 follow-through: `NewsletterSender::queueAll` now consumes its INSERT IGNORE verdict through the `WriteResult` helper.** The original `a7536372` hand-rolled `$created !== null` — CI's `WriteResultAdoptionGuardTest` (the S342 single-idiom law) correctly flagged the site, and the flag is load-bearing: a `false` return (statement error) passed `!== null` as a phantom "created", and the non-auto-increment UUID PK makes `lastInsertId()` return the string `'0'` for REAL writes — the exact falsy trap the helper exists to end. `!WriteResult::wroteNothing($created)` is now the idiom; the denominator inventory pin rotated 20→21 measured from the CI red (run 41731180402) with the staircase attribution. - **Deferred #9 follow-through: the extension-contract test records Composer's vendor-declared additions instead of pretending they cannot exist.** `justinrainbow/json-schema ^6` pulls `marc-mabe/php-enum`, which declares `ext-reflection`, so `vendor/composer/platform_check.php` now enforces one extension more than the call-site contract lists — and the contract deliberately never lists reflection (PHP cannot be built without it; its own header says so). The equality law became two documented exemption classes: `POLYFILL_PROVIDED` (may be absent) plus a new `VENDOR_DECLARED_ADDITIONS` (may be extra), guarded by an anti-staleness assert that reddens if a recorded addition ever appears on the contract side. A new vendor extension in the generated check now fails until it is recorded — honest information, never silent drift. - **CI cure (baseline-owed): the LiveTv hostile-key probe now types its own intent.** Psalm's tests corpus had flagged `tests/Unit/LiveTv/LiveTvManagerTest.php` InvalidArgument since the campaign tip (red on every push CI of the deferred lane, inherited at 98d7f691): the probe passes an extra `evil` key through `updateTuner()`, whose param shape is deliberately closed — the shape strictness IS the allow-list law, and the junk key IS the test. Cured with the estate's established `@psalm-suppress InvalidArgument` malformed-input-probe precedent (AuthMethodPolicyTest), annotating exactly that call. - **Deferred #2 follow-through — three byte-exact wire pins rotated for the no-store seed.** The CI full-suite runs caught what the #2 targeted battery missed: `RouterTest`'s gated-HEAD refusal pins and `RouterHeadNotFoundTest::testAGetToAnUnregisteredPathStillShipsTheWholeEnvelope` compare the live reply against the framework encoder's bytes, and every refusal they capture is a `json()` reply — so the intentional `Cache-Control: no-store` seed moves their expectations. Rotated from the measured reds (head 41728289156, three named failures reproduced locally before the edit); the head-only, single-Content-Length, 83-byte-body and encoder-derivation laws are the tests' real subjects and stand untouched — including the pre-fix CONTROL sites, which encode their own bytes and therefore stay seed-less honestly. - **Deferred #68 — internal tracker shorthand is out of the user-facing docs.** `(R14-05)`, `(S152)`, `SV-4.15`, `updates.md #47`-style review-tracker IDs sprinkled through README.md (env-knob table, deploy notes, CLI exit-code sections, API table) and RELEASE_PROCESS.md (the release-authority and tagging lines) are meaningless to readers outside the estate's review process and were flagged by the end-user review. Each sentence now carries its meaning on its own ("pre-S85 names" → "historical names", "before minors M-SERVER-72" → "previously", "the S474 regime" → "the current regime"); the README paragraph that apologized for the annotations is gone with them. Code comments and test provenance keep their ticket IDs — those are maintainer surfaces and out of scope. README/RELEASE_PROCESS referrer battery green at 380 tests. - **Deferred #44 — ALL-CAPS prose emphasis no longer double-doubles bold.** Nine sentence-level caps (`ALWAYS`, `ONLY` ×4, `NOT` ×4) in README.md and RELEASE_PROCESS.md moved to bold, joining the bold emphasis already used in the same sentences. The deliberate boundary: SQL keywords (`IF NOT EXISTS`), imperative comments inside fenced install procedures (`MUST include DROP`, `do NOT one-line-prefix`), and semantic status markers (`NOT SHIPPED`, `NOT-YET-PUBLISHED`, `NOT implemented here`) are content or code, not emphasis, and stay caps; CHANGELOG history is verbatim and untouched. README/RELEASE_PROCESS-referrer guard battery green at 380 tests. - **Deferred #29 — one heading capitalization convention across primary docs.** Sentence case was already the dominant style and the house voice; the remaining Title-Case outliers (`### Web portal` et al. in README.md, `## Installing the chart`/`## Configuration options` in k8s/README.md, and the whole `## Release Steps`/`### 1. Create release branch` family in RELEASE_PROCESS.md) now follow it. Proper nouns, brands and acronyms keep their canonical spelling (SyncPlay, WebSocket, MySQL, Docker, API, HLS); semantic status caps are content, not style, and stay (`NOT SHIPPED`, `NOT implemented here`). In-prose heading cross-references and link display text were carried along; GitHub anchor slugs are case-folded, so every `#section-anchor` link resolves unchanged. README-referrer guard battery (Backup/AuthMethodLockout/TrustedProxies/PositionTolerance/PasswordReset/ InstallerFirstRun/LogEnvironmentKnobs + the S431 census, whose write-denominator rotation 1118→1119 belongs to deferred #73's router pin) green at 374 tests. - **Deferred #73 — the library drill-down listing now serves a total.** `GET /api/v1/libraries/{id}/items` was the estate's only paged listing returning `{items, limit, offset}` without `total`, so web-UI clients could not render a page count or detect a past-end offset (the sibling `GET /api/v1/media` contract has always carried it). The response gains `total` — the row count of the **filtered** set, computed by the new `ItemRepository::countInLibrary()` which mirrors each SELECT branch's WHERE exactly: the `?type=` equality when that branch ran and the parental content-rating cap via the repository's shared `ratingCapClause()` fragment, so `total` and `items` can never disagree about what the active profile may see (a rating-blind total would leak the uncapped library size). Additive payload key on a route already documented with `additionalProperties: true`; no route, manifest tuple, or wire-code moves. - **Newsletter re-fires can no longer double-queue (and double-mail) a recipient.** `newsletter_queue` shipped its `(user_id, week_start)` index NON-UNIQUE (migration 020) and `queueAll()` inserted blindly, so a re-fired weekly campaign appended a second pending row per already-queued user. Migration 125 collapses any pre-existing duplicates deterministically (earliest `created_at` per group survives, `id` tiebreak) and lands `uk_newsletter_user_week`; `queueAll()` becomes `INSERT IGNORE` and counts only rows ACTUALLY created (vendor `Connection::query()` returns `lastInsertId()` for a changed INSERT and NULL for a skipped duplicate — pinned by the unit test's mixed callback). The SELECT-without-FOR-UPDATE process-claim half of the review stays documented-safe under the count=1 timer worker, unchanged. (Deferred-items lane, review minor #54 / dba2 m2.) - **Two pre-existing CI reds at the campaign tip are cured (baseline repair, no behavior change).** (1) Batch-17 (6437e318) excised the dead `run()`/`handleException()` starters from `Application.php` but left three now-unreferenced imports (`LogChannels`, `LoggerFactory`, `Throwable` — every surviving reference is fully-qualified), reddening the S187 whole-tree unused-import guard; excised (57a96f55). (2) The same batch added a third SecurityHeaders funnel (raw-reply 500 decoration) without rotating the wiring guard's two-funnel count pin; rotated 2→3 from the measured red with attribution, the law (one construction, never store-less) untouched (b7718586). Both author-misses disclosed; the guard rotation is the sole test failure in the origin CI red run at 98d7f691 (41714136593). - **Boot-degradation lines are now identity-stamped log-channel records.** Two boot paths that strand an admin control — an unusable `worker.pid_file` (restart rail silently falling back to Workerman's default) and a SyncPlay-bridge socket collision between two daemons started from the same checkout (documented loss posture) — used to report as anonymous warnings no journal could attribute. A new `Phlix\Server\Runtime\BootIdentity` stamps once per master boot (instance token, pid, started-at; first stamp wins, later calls can never re-key correlation); the pid-fallback branch now emits an `error_log()` record naming the configured path, the consequence, and the identity, and the bridge listener stamps a 0600 `<socket>.owner` sidecar on bind so the loser's refusal message NAMES the owning instance (stale sidecars are overwritten; a never-bound `close()` cannot erase the live owner's stamp; a missing sidecar reads honestly as "owner stamp absent"). The fail-loud alternative was weighed and rejected — the ratified serving-over-restart-button posture (M8 boot-abort family) stands; what was missing was correlation. The `/health`-payload half of the review ask stays wire-gated with the M18 readiness-split family. (Deferred-items lane, review minor #15 / devops2 m2.) - **Authenticated JSON APIs now ship `Cache-Control: no-store`.** Every `Response::json()` reply — the whole portal/API surface (settings, continue-watching grids, transcode status polling, and every canonical error envelope) — previously carried no cache policy at all, so browsers and intermediaries retained per-user dynamic bodies by date heuristics and status polling could be answered from a stale cache. `json()` now seeds `no-store` on the seed-don't-overwrite pattern `html()` uses for its `no-cache` default — with the case-insensitive recognition the L-7 guard doctrine requires (RFC 9110 §4.2), so a caller policy set in any header case wins and never coexists as a duplicate. Callers that set their own `Cache-Control` (the immutable artwork rails, the DLNA `no-cache, must-revalidate` family) win in either direction of the chain. ETag/304 revalidation is a separate performance question and deliberately not mixed into this correctness fix. (Deferred-items lane, review minor #2 / fedev m2.) - **Review batch 21 — README narrative and mount truth (Minors 4, 62-67, 69-72).** The Overview now leads with what the household gets ("your own streaming service for the media you already own") before the platform sentence; casting is positioned as four protocols with full transport control (Chromecast/AirPlay/Roku/DLNA, each behind `CastingEnabledMiddleware`/`casting.<protocol>.enabled`) instead of the one-line "DLNA Streaming"; shipped background subsystems (Trakt/Last.fm scrobbling, trickplay thumbnails, intro/outro skip markers, box-set sync) surface as capabilities in the Overview; ffmpeg-level normalization detail (`channel_configuration=0` PCE, `force_divisible_by=2`) moved out of the buyer-facing list into [HLS & transcoding]. Installation gained a first-class Docker pointer (`docker/README.md`), the repo-move notice left the position directly under the title (and its frozen "being archived in step B.4b" transition clause was dropped) to become an "Updating a clone of the old repo" section, and Troubleshooting opens with five no-terminal first steps for non-technical household members. `reverse-proxy/README.md` states the root-mount constraint the code enforces in fact: the shell is built with root-absolute `/assets/app/…` URLs and routerBase `/app`, so path-prefix mounts without upstream rewrite break SPA boot — host per domain/subdomain. Verified already-honest in place: daily watch-time limits already lead with "stored, not enforced" (Minor 69); the group/room/watch-party naming law is already documented (Minor 66 — consolidation stays an open product decision). No behavior or wire changes; doc-only batch. (Phlix review program — server Minors 4, 62-67, 69-72) - **Review minor 58 — the unscoped browse rail stops filesorting.** New migration `124_media_items_type_sort_index.sql` adds `idx_media_items_type_sort (type, sort_title, name)` to `media_items`, so `ItemRepository::getAllByType()` (`WHERE type = ? ORDER BY sort_title, name, id`) reads its partition in index order: `type` is pinned by the equality, the index supplies the `sort_title, name` order, and InnoDB's implicit PK suffix covers the trailing `id`. Measured three-way on MySQL 8.0: with the index `key=idx_media_items_type_sort` and no `Using filesort`; without it `idx_type` + filesort; and the review's suggested `(type, sort_title(64), name)` PREFIX variant measurably does NOT kill the filesort (a prefix index cannot order a VARCHAR(255) column — the optimizer declines it), so the full column rides instead. Guard idiom per 107/121/123 (INFORMATION_SCHEMA + PREPARE; replay short-circuits, proven twice on MySQL and verbatim twice on MariaDB 10.6); the runner's full-chain apply is green with it in the tree. 050's library-led composite stays — the library-scoped browse cannot use this one, and vice versa. - **Review Minor static/SQL-law cluster (minors 3, 55, 56, 57, 74).** The rest of the public static tree (legacy `public/assets/{css,images,js}` residents, favicon, robots) now gets the same fail-closed `Cache-Control: no-cache` the unhashed members of the Vite tree already received, with `Last-Modified` as the validator — stale-after-deploy heuristic caching ends (minor 3's cache half; its ETag note stays a perf idea). The SPA bootstrap injection is anchor-robust (`<head ...>` / any casing re-emitted verbatim) — a build shape-shifting the head tag can no longer silently strand the SPA without `window.__PHLIX__`; regression test covers attributed, uppercase and mixed forms. `UserRepository::countUsers()`'s SQL-fragment parameter gained the house allowlist law (server-authored literals only; call-site census in prose; minor 55 kept-with-law disposition). The dead `QueryBuilder` — zero production instantiations repo-wide, whole-table-write footgun by design — was deleted with its own-test (minor 56); the census denominators re-pinned from measured reds in the same commit (2043 files; declared-reads 457 — the +2 there belongs to batch 18's carried logout tests and its rotation was owed there, disclosed). The three contradictory LIMIT docblocks now state ONE canonical law (validated int; clamped-inline is the estate default because emulated-prepare paths re-materialize `LIMIT '20'` and MySQL 1064s it; existing bound sites grandfathered where the vendor prepared transport is constructor-typed) — prose-only, no query changed (minor 57). The `getMedia` doc example's `runtime` shows 120 — minutes, as TMDB defines it — instead of 7200, which read as seconds and invited a client formatting bug (minor 74). - **Review Minor copywriter cluster (minors 45-53): human validation copy on member/admin-facing envelopes.** Error keys, status codes and payload shapes are byte-identical throughout — only message strings move (C2 copy model, per the profile-copy precedent). `WebPortalRouter`: 'Media type is required', 'The around value must be between 1 and 300', 'The limit value must be between 1 and 100', 'Search text is required', 'The submitted settings are not valid', 'Invalid person ID' (sibling capitalization); the field-keyed coded-validation half stays deferred (wire contract, pairs M13). `hub_token is required in request body` → 'Hub token is required' (AccountLink + HubToken controllers, moved as one string). Smart playlists: 'Name, library and rules are all required', 'Playlist ID is required', and the `json_last_error_msg()` passthrough now ships 'The playlist rules are not valid JSON' while the raw parser detail goes to `error_log` (never to the member's screen). Chapter copy drops the index jargon and the 'on disk' filesystem leak. Trickplay 404 messages rename the internal feature + format acronym to 'Preview data/image/timeline not found' (the second-key posture itself unchanged; the wire-distinguisher guard test rotated in the same commit). Join/leave announcements say 'A viewer joined/left the group' when the display name is missing instead of leaking the 'Unknown' sentinel — the roster and wire `member_name` value keep 'Unknown' untouched. Admin copy: punitive 'User rejected successfully' → 'Request declined'; backup type-vocabulary strings reworded. 'Settings saved successfully' (GitHub/ LDAP/OIDC controllers + their templates' fallback) and 'Stream limits updated successfully' unify onto the no-adverb house style already kept by the core settings surface. Carries the three forced-logout gate tests of minor 21 that batch 17's pathspec accidentally left uncommitted (they pass against the committed HttpHandler legs — 12 tests/55 assertions green). - **Review Minor code cluster (fedev m1, srphp 5, secaudit 1, asphp 1/2/3; devops2 3 docs half).** (m1) The last-resort 500 in the Workerman HTTP handler now wears the same `decorateRawReply()` coat as its static and CSRF-403 siblings — nosniff, CSP, and the ACAO echo a cross-origin client needs to READ the JSON fallback (generators re-derived locally; a pre-parse crash has no origin to echo). (srphp-5) The dead CGI-era path is excised: `Application::run()` + `handleException()` and their three private subsystem starters (`startRelay/HubHeartbeat/Discovery…`) — no caller in any tree since S171 — go, along with their now-orphaned imports; the seven docblocks that referenced them (Router, HttpHandler 429 comment, `start.php`, four test files) rotate to past-tense truth, and the F16 single-source funnel test re-blesses from its MEASURED red in the same commit (Application may now arm the timer funnel ZERO times from inside itself — a resurrection is what that pin catches). (secaudit-1) `GET /auth/logout` — the plain-`<a href>` logout leg — joins the cookie-auth CSRF gate through a new method-free `validateSameSiteOrigin()` (the Origin/Referer law extracted from `validateCsrfOrigin()` unchanged); a cross-site `<img>` forced logout now dies with the identical 403 envelope while same-origin link logouts pass untouched; every other GET keeps its exemption. Three new handler-leg tests pin all three shapes. (asphp-1) `TimeSync` refuses physically-impossible negative-RTT pongs into the offset window (they dragged `getEstimatedLatency()` negative). (asphp-2) The wildcard dispatch arm gets the named arm's try/catch — extracted to a shared `reportHandlerFailure()` so the two can never drift; a throwing wildcard no longer escapes toward `Worker::stopAll()`. (asphp-3) `SyncPlayManager::broadcastToGroup()` applies the backpressure law its three MessageHandler siblings carry — a full-buffer member is skipped (Workerman silently discarded the frame anyway; the position-report loop re-converges them) instead of pretending delivery. (devops2-3 docs half) The env census's `PHLIX_REQUIRE_DB_CREDENTIALS` row finally says what operators should do (set `1` in production) and why the default flip waits for the owner. - **The docs cluster of the review's Minor findings — thirty prose cures, one voice.** (a) README: "LiveTV" standardized to "Live TV" in prose (identifiers keep `livetv_*`); the dangling "creates" gets its object ("creates a collection"); the pipe-joined legacy-path list reads as paths, not regex alternation; the arrow-initial table sentence becomes "Success →"; the "Admin Section" heading joins sentence-case; the FFmpeg requirement bullet lowercases its continuation; the timeshift rating sentence loses its nominal stack; the Swoole requirement row is scoped honestly (the Alpine image bundles it, `Dockerfile.nvidia`/`Dockerfile.intel` install PHP from apt without it — so pooled coroutines do not apply there); three drifted `Application.php:NNNN` endpoint cites re-anchor to their symbol; the env-census scope sentence gains its truthful exception clause and `PHLIX_ANIDB_CACHE_DIR` finally has its census row (consumer: the vendored anidb plugin, set by the shipped unit); a bare-metal exposure note lands where the `0.0.0.0` sample lives. (b) Security docs: the deprecated `X-XSS-Protection` header is dropped from both reverse-proxy templates and its feature bullet; the proxy README stops teaching the retiring, log-leaky `?token=` carrier (bearer subprotocol + query-logging warning); a mixed-content paragraph explains WHY the `wss://` fronts are not optional (https pages cannot open `ws://` sockets, silently); systemd/README carries the same plaintext-:8097 exposure note the compose docs already had. (c) Dev docs: the AUTH_METHOD_LOCKOUT json fence is now actually valid JSON (the union notation moves to the table above it); data-model.md's numbering law states the truth (ordinals are NOT unique — 003/004/062 pairs ship — same-prefix order falls out of the file-name sort, `012a` is the one alphabetic deviation) and classifies `080_backfill_missing_profiles.php` as the runner-invisible manual diagnostic it is; its catalog rows re-enter ordinal order (106 before 108, 012 before 012a); settings-restart-gap's headline arithmetic matches its own table (fifteen live keys, five asymmetric, one deleted row); the 31-line `--testdox` paragraph in skipped-test-names.md becomes a bulleted decision list; RELEASE_PROCESS stops saying "below" for the table above and the reversed-twin section titles get disambiguated; docker/examples README standardizes on the plugin `docker compose` form and recovers two dropped articles; the AGENTS/CLAUDE Media section documents the CURED transactional stream-replacement law (the "neither is transactional / permanent partial set" claim was stale since `replaceStreams()` grew its transaction), E10 gains its watched/unwatched carve-out, and the bare-phpunit comment names all three suites including the serial E2E tail; the Dockerfile headers stop citing the dead `php:8.3-fpm-alpine` base; the entrypoint's php-fpm `clear_env` caveat retires with the program that died in S163; the MigrationRunner dialect docblock re-quotes the README's real floor (8.0.13) and names the M28 census it now pins; BLOCKING_IO's Exception-7 cells close their sentences. - **The MariaDB dialect gate can no longer shoot migrations in the foot (review M28).** - Migrations 121/123 carried `@phlix-require-mysql` directives that were wrong twice over: the blocks they guarded are NOT MySQL-only (measured — the full SET + PREPARE/EXECUTE/DEALLOCATE guard idiom, the STORED generated column with JSON extraction / CASE / REGEXP / LEAST-GREATEST clamp / CAST AS SIGNED, and idempotent replays all execute cleanly on a fresh 10.6.28-MariaDB scratch DB, with byte-equal `year_sort` semantics: path-missing NULL first, digit values exact, junk folded to 0, out-of-range clamped), AND the directive binds to the NEXT flushed statement only — on any gated server it skipped just the `SET @preparedStatement` half, leaving `PREPARE … FROM @preparedStatement` reading a NULL variable (ERROR 1064 measured on the same server), which is not a squelchable idempotent code: the file stayed permanently un-recorded and every migration run exited red. The two directives are REMOVED (comment-only edit — ledger checksums unchanged, installed machines re-apply nothing, pinned by the existing checksum-mirror test now extended to 121/123). With them gone the chain's directive census is exactly 011 (x3) + 050 (x1) — the set the README's MariaDB-support parenthetical enumerates — so the advertised support is true again and `sort=year` no longer dies with 1054 on MariaDB (the column now exists there). - The runner gained structural teeth: `rejectDirectiveOnDynamicDdlFragment` throws on ANY server when a directive guards a dynamic-DDL fragment (SET/PREPARE/EXECUTE/ DEALLOCATE) — a mis-gated file is a configuration bug, not a dialect fact, and no half-guarded block may execute anywhere. Two new MigrationRunnerTest laws pin it: the fail-loud behavior (red without the guard — mutation-proven) and a real-chain scan asserting the directive census and whole-statement placement. Census forward-fix in the same commit: EXPECTED_PHP_FILES 2044→2045 from the measured red, attributed to commit fd490c07 (M17 lane) adding RegisterDuplicateKeyRaceTest.php without rotating. - **Recovered docs/ops Major cluster (review M27, M29–M38, M43) — deploy documents tell the truth.** - README Requirements now state the host installer's **Linux kernel ≥ 6.9 floor** (the installer compiles Swoole with io_uring ON; the FATAL-below-6.9 fact its own comment records existed nowhere a fresh-OS reader would see), the plugin bullet qualifies "signature-checked" honestly, the env-census row carries the real `false` default, and a new **Plugin trust model** narrative separates the default-deny catalog channel from the opt-in manifest-signature channel (a tampered signature is always refused; an ABSENT one warns unless `PHLIX_PLUGINS_REQUIRE_SIGNATURE=1`). - The Helm chart gains `phlix.rejectKnownPlaceholder`: rendering `values.example.yaml` verbatim (which shipped `CHANGE_ME` and the literal instruction `openssl rand -hex 64` in secret positions) now FAILS with a remedy-bearing message instead of booting with a publicly-known JWT key — the docker/examples `.env.example` doctrine applied to the chart, covering `config.database_password`, `config.secret_key` and both bundled mysql.auth passwords (both arms proven with a real `helm template` render). - README self-contradiction on the WebSocket catalogue fixed: **27 wire events** (matching both its own list and the 28-const-minus-`SYNCPLAY_PREFIX` census in `WebSocketEvents.php`). - Prose defects: the dangling "; if it has no saved config" clause repaired; the dead `../README.md` link in AUTH_METHOD_LOCKOUT.md re-anchored (`../../`); the grammar break in docker/README.md fixed; stale `start.php:NNN` coordinates in BLOCKING_IO_EXCEPTIONS and SYNCPLAY_WRITE_THROUGH_BRIDGE replaced with drift-proof symbol names; `scripts/bench/README.md` drops the never-existing `PHLIX_EXPANSION_PLAN.md` citation and the stale v1.0 framing; the three future-tense `wsUrl` seam narrations (docker/examples, nginx, caddy) rotate to shipped state while keeping the "do NOT add /ws against :8096" warnings. - `server_deploy.md` renamed to `docs/dev/ui-bundle-vendor.md` (its actual content), gains the missing `npm ci` fetch step in BOTH options (a literal one-action-per-step read silently rebuilt stale chunks), its impossible live-verify curl replaced with reading the SPA shell's hashed entry reference, and a README pointer fixes its orphan status. - Webhooks + access schedules join the README feature narrative (honestly: the signed registry vs the UNSIGNED domain-event subscriptions surface), three endpoint-table rows grounded in the registered routes, and an access-schedules cross-link in the profiles section. - **Profile refusals stop reading like syslog and start reading like help (review M25, copywriter).** - The access-deny family that a child's device can be the only reader of — 'No profile found; access denied', 'Profile not found; access denied', 'Access denied during scheduled window' — now names the household member profile (not the quality-profile sense of the word), tells the reader the hours are set by the account, and points at the adult remedy. The stream-side 'No active profile …' triplicate and the concurrency-cap message joined the same noun. Statuses, `error`/`code`/`denial_type` keys and envelope shapes are byte-identical (copy-only per the C2 model); the two schedule refusals are one shared constant so they can never drift apart again, and the head-no-body wire probes single-source the copy too. - **Error labels in account-link, OIDC and admin restart/settings responses now follow one punctuation law (review M24, copywriter).** - Eight single-clause `message` strings across four controllers shed their trailing periods to match their period-free siblings ('Invalid credentials', 'Settings updated') — clients render these raw and the same-API drift read as a bug. Multi-sentence guidance copy keeps its grammatical periods. Shapes, keys and codes unchanged. - **Thirty unwired-subsystem 503s stop reciting internal wiring vocabulary (review M23, copywriter).** - The web portal's "Watch history/Profile manager/Favorites/Ratings/Similar items/Collection service/Recommendations/Transcode/Settings persistence/Theming/Artwork storage/Avatar service/Music library service is(are) not configured (on this server)" family — 30 member-facing arms reached by ordinary signed-in users whenever an optional subsystem is unwired — now answers one human sentence: "This feature isn't available on this server right now. Please try again later." 503 statuses and the single-`error`-key envelope shape are unchanged (copy-only per the C2 model); the machine-readable `code` half of the proposal adds a payload key and joins the M13 deferral (contract registry territory). The two law pins that sample the copy (wire-path 503 guard, theme reachability) followed the string; every other arm was unpinned. - **The hub-enrollment refusal sentence no longer spells its own word two ways (review M22, code half).** - "This server isn't **enrolled** … **Enrol** it from Admin → Hub" became "This server isn't enrolled with a Phlix Hub yet. Go to Admin > Hub to enroll it." across all three wire carriers (admin health, account-link, hub-token) and the canonical-copy law pin — the `→` glyph (broken on TV fonts and screen readers) is gone too. Envelope shapes and the `hub.not_enrolled` code are byte-identical. The estate-wide British/American convention for docs (catalogue/enrolment/behaviour outliers) stays an owner call — logged, not churned. - **The weekly newsletter's text half now declines like its HTML half (review M21, dba2-docs).** - One email printed "1 plays" and "Watch Time: 1 hours" in plain text while the HTML template beside it already knew "1 play" — and zero new items read as "New Items Added: 0" where "No new items this week" is the sentence a person wrote. Both legs now share the template's grammar rules, including numeric-string play counts from the database face. - **Two people signing up with the same name at the same instant now get the honest 400, not a 500 (review M17, dba2).** - Registration checks username and email availability before its transaction, so a concurrent loser's INSERT previously died on the database UNIQUE with a raw duplicate-key error — surfacing as a generic 500 even though the answer ("Username already taken") was knowable. The INSERT failure now translates into the exact 400 copy the pre-check produces (JSON and browser-form arms alike, no new codes); unrelated INSERT failures keep their verbatim exception identity — a PRIMARY collision is a real fault, never a friendly rejection. - **The relay-tunnel worker no longer opens an unintended listener on every interface (review M20, devops2).** - Both places that started it passed `text://0.0.0.0:0` "purely to get a timer context" — but Workerman binds and listens on any socket name, so every server was exposing a kernel-chosen ephemeral port on all interfaces: unauthenticated peers could pile connections onto the count=1 relay fork (fd/memory exhaustion takes down 100% of relay), and the random port could not be firewall-pinned. The worker is now socket-less, exactly like the repo's own hub-heartbeat and background-timer workers, which run the same timer job with no listening socket at all. - **An absurd `?offset=` can no longer buy a full scan of the media table (review M16, dba2).** - `ItemRepository::normalizeOffset()` clamped negatives only: `LIMIT` had the S99 row ceiling while `OFFSET` was unbounded, so `?offset=999999999` made MySQL scan-and-discard a million-plus rows on the largest table inside a resident worker. Offsets beyond a documented 100,000-row pageable ceiling clamp silently — the same tolerance law an out-of-range `limit` already gets — and in-range pagination is byte-identical. - **DVR recordings that merely aren't ready yet stop lying that they don't exist (review M15, webux).** - `LiveTvStreamController::streamRecording()` answered three different states with one bare 404: a not-found recording, a not-yet-started (`scheduled`) recording, and a completed recording whose `.ts` file has not hit disk. The SPA rendered "Not found" where the truth was "try again in a moment." The two not-yet-materialized arms now return 503 + `Retry-After: 2` — matching this controller's own timeshift arm and the HLS/DASH segment-busy arms — while a genuinely absent or terminal (failed/cancelled) recording keeps its 404. Response body shapes are unchanged (no new `code` key — that half of the review rides the M13 wire-code decision); only the HTTP status and the standard `Retry-After` header move, the same carrier the 429 mapper already uses. - **Live-TV tuner updates and deletions now go through the manager that owns the table (review M11, srphp).** - `AdminLiveTvController` had grown inline `UPDATE`/`DELETE` SQL for `livetv_tuners` on its own borrowed pooled handle (the comment confessed the manager lacked the methods). Those write semantics — the mutable-field allow-list, `enabled` coercion, cache sync — moved into `LiveTvManager::updateTuner()`/`deleteTuner()`, beside the table's other writers, and now carry domain tests that the HTTP-layer placement made impossible. The endpoint's validation behavior is kept exactly (mistyped `name` and explicit-null `enabled` still write nothing; the re-read still serves the cached tuner object with the same keys as before). One deliberate improvement rides along: operator edits now stamp `updated_at = NOW()` like every other tuner write — database bookkeeping only, never wire-visible. - **The admin dashboard stopped polling the database once per row (review M10, srphp — AGENTS.md's own batch-query law applied).** - One refresh of Now Playing / Top Media / Recent Activity cost roughly 3N queries for N rows: a `findById()` per stats row, a `getSession()` per stream, and a raw single-id `SELECT username` per row. All three surfaces now hydrate items through one `ItemRepository::findByIds()`, sessions through a new `SessionManager::getSessionsByIds()` (in-memory cache first, one query for the misses — identical per-id semantics), and usernames through one `WHERE id IN (...)` map. The S14 orphan-skip and S220 hide-deleted laws are unchanged — a map miss is exactly the old per-row null — and an executable law pin now reddens any regression back to N+1. - **A wiring failure that removes an API surface is now logged instead of vanishing silently (review M8, srphp).** - Route-registration and subsystem-start blocks in `Application` caught every resolution error with a comment-only body: a broken DI binding made clients get 404s (or the relay/hub/discovery workers stay down) while the daemon booted green. The estate fail-loud law is restored at the minimum without changing boot behavior: every swallow site now `error_log`s the feature name and the driver message. Sites whose degrade path is already client-visible (a per-request 503) and the legacy container-less DB fallback chain stay as they were, documented in the review ledger. - **The parental-rating gate on the direct-play fast path announces its own failure (review M9, srphp).** - `PreRouterFastPaths::ratingGate()` returned null on any container error and the stream branch treats a null gate as "no cap", so a transient wiring fault served capped-profile streams ungated with zero log output. The degrade direction (keep playing rather than black out the server) is unchanged; the fault is now logged on every occurrence — a silent security-control degradation must be visible. The stricter alternative (503 for capped sessions) is an availability trade kept for the owner, recorded in the ledger. - **A half-dead hub relay tunnel is now detected from the READ side instead of being advertised healthy forever (review M1, asphp — the `ping_timeout` knob finally has a consumer).** - The relay worker's HEARTBEAT frames are fire-and-forget and a failed tunnel-scoped send only warns, so a NAT-expired (one-way blackholed) tunnel — the exact case the relay exists to serve — kept passing frames into a void while the heartbeat tick stamped the persisted relay state healthy; death was only noticed once the 16 MiB egress cap saturated. `RelayConfig::$pingTimeout` documented "seconds before considering the tunnel dead" but was read by nothing outside its own config class. New: every inbound tunnel byte stamps a monotonic liveness clock, and the heartbeat sweep reaps (close → the existing `handleDisconnect` teardown + backoff reconnect ladder) any ACTIVE tunnel silent beyond ONE hub heartbeat cycle plus that grace — the cycle is in the ceiling because the hub master never replies to our heartbeats; its own keepalive sweep is what moves a healthy idle tunnel's inbound clock, so a bare 10s cut-off against the 30s cycle would flap live tunnels. `ping_timeout < 1` disables the watchdog; the knob's docblock now states the shipped semantics. Zero wire/frame change (reuses the existing HEARTBEAT keepalives); the relay-state file shape is untouched. `RelayConsumerTest` +4 (red-first mutation-toothed): 41s silence on default knobs reaps and leaves no stale clock for the next tunnel, in-ceiling silence survives, one inbound keepalive byte resets the clock, and timeout<1 disables. - **Idle WebSocket timeouts no longer leave ghost members in watch parties (review M2, asphp — the ping-sweep R16-02 cure's overlooked twin).** - The 60-second receive-side idle sweep lived in `ConnectionPool` and closed stale wrappers with sendMessage + close + remove() directly. Workerman's `close()` DEFERS `destroy()` — and with it the worker's onClose — while the send buffer still holds bytes, and once remove() had un-pooled the wrapper a late onClose found no pool entry, so the entire teardown arm (SyncPlay vacate + final metrics touch) was skipped: an idle-timed-out member lingered in its active group as a ghost (possibly as its host) until the hourly stale-group cleanup, and its opened-connection marker leaked for the worker's lifetime. The sweep now runs server-side (`WebSocketServer::reapStaleConnections()`) through the same finalizing `reapConnection()` path the ping sweep uses, and the bypass- prone pool method is removed. Wire-identical: the stale peer still receives the same `timeout` notice (`{"type":"timeout","data":{"message":"Connection timed out"}}`) immediately before its socket closes, and the strict greater-than idle boundary is unchanged. `WebSocketServerReapTeardownTest` +2: ordered send-before-close capture with vacate/group-deletion assertions (mutation-toothed — reverting to the raw close/remove path reddens the vacate pin), and a fresh-connection survivor pinning the boundary. Zero new wire codes; no payload-shape change. - **Theme music streaming no longer buffers whole multi-MB files into the resident HTTP worker (review M3 — the closed blocking-I/O register's unlisted exception).** - `ThemeMusicStreamController::streamFile()`'s whole-file leg answered with `file_get_contents()` into `->body()`, the exact pattern AGENTS.md's S3 doctrine reserves `Response::withFile()` for (HLS/DASH precedent). It now hands the path to the event-loop file sender; explicit Content-Type/Content-Length/ Accept-Ranges/Cache-Control headers are unchanged (the withFile header mirror emits the same values). `ThemeMusicStreamControllerTest`'s whole-file law was rotated with teeth: the response body must be EMPTY pre-materialize (a buffering regression reddens) and `materializeFileWindow()` then asserts identical bytes. The Range leg keeps its bounded `fread()` (≤ requested window, honestly capped). Zero wire change. - **The shaped media list's `genres` and `overview` can no longer carry hostile row shapes to clients (review M7 — type law their siblings already kept).** - `MediaItemShaper::shape()` passed `metadata_json` fragments straight through: a malformed row could ship `genres` as an object/map or `overview` as a non-string, breaking every consumer that trusts the declared `list<string>` / `string|null` types. `genres` now runs through a name-unwrapping `normalizeGenres()` (map/object/bare-string hostile rows all leave as `list<string>`; legitimate `{name}` rows keep their data) and `overview` takes a strict `is_string()` guard. Well-formed rows ship byte-identical — the pre-existing pins are unmodified — with +1 hostile-rows law-pin test. - **Watch-party playback no longer rubber-bands backward every few seconds (INCIDENT playjump — the group's echoed position now advances while playing).** - A `playback_sync` echo answered with the raw group ANCHOR — the last explicit host play/pause/seek position — while the group kept advancing on members' players, so each ~5s echo dragged drift-correcting clients back to a stale second ("jumps back a few seconds, wasn't there before"). The anchor is now stamped at every write (`Messages::nowMs()` scale, never `time()` — the two-scale class is part of this incident); the echo serves anchor + elapsed at 1× (exact milliseconds, elapsed clamped at zero, result clamped to `[0, duration]` when a duration is known, raw anchor when not playing), and the echoed position, the nudge's `position`/`drift_ms`, and the S446 in-sync predicate all hang off ONE reference tick per ingest, so a frame can never carry a position and a drift that disagree. Payload keys and every envelope are unchanged — the VALUE law is what moved; zero new wire codes (the contracts registry md5 stays `40c1da48`). - The stamp survives a worker handoff: `GroupState::serialize()` gained ONE mirror-internal key, `playback_position_at_ms` (the live-only draft was measured insufficient — a restore silently discarded 90s of advancement: the rubber-band once per deploy; the continuation test pins it). `getState()` and the public broadcast shape are untouched; blobs predating the key self-heal to restore-time-now and can only under-claim elapsed, never inject a forward leap. Deterministic proof: two clock-seam manager tests assert the echo delta equals the injected tick EXACTLY (5000ms → 5000ms) — red on the frozen anchor at the parent. SUPERSEDES, forward-only (pushed history untouched): the same-day duplicate-lane entry below ("rubber-band cure — [review incident playjump]") shipped the same anchor stamp and mirror key but with whole-second quantization and a deliberately RAW nudge/predicate reading; arbitration rejected both halves — quantization leaves two echoes inside one second identical (the frozen-anchor defect in miniature), and mixing an advanced echo with raw drift breaks the one-reference-instant law the clients key their seek on; that lane's clock-frozen echo test is removed with the file count re-pinned around it. Disclosed goldens: the nudge test's and E2E Tests 12/13's exact-5000 echo pins became bounded-range + frame-identity assertions (they had survived on a sub-millisecond timing accident); the S431 census re-pinned 2041→2042 from its measured red (+2 files: `GroupStateAnchorAdvanceTest`, `SyncPlayEchoAdvanceTest`; −1 superseded lane test); `docs/dev/SYNCPLAY_MEMBER_SYNC_NUDGE.md` updated for the one-tick drift law and the new mirror key. REST/snapshot surfaces keep answering the raw anchor by design (scope: the echo chain only). The hub :8804 relay twin lands separately with verbatim-mirrored semantics. - **Watch-party playback no longer rubber-bands backward every few seconds (INCIDENT playjump — owner incident directive 2026-11-02).** - The playback-sync group-state echo carried the RAW group anchor as its `position` while state=playing, and the anchor moves only on explicit host play/pause/seek — so every ~5s report echoed a stale second and drift-correcting clients seeked backward onto it ("periodically jump back a few seconds"; evidence chain .reviews/INCIDENT-playjump-fieldtest.md / -era.md / -server.md, hls.js recovery suspect refuted there). Canonical cure semantics, which the hub twin adopts verbatim: while playing, the echoed position is ANCHOR-ADVANCED — `pos = anchor + floor((nowMs − anchorAtMs) / 1000) · 1000 · rate`, clamped to `[0, duration]` when the duration is known; paused/stopped keep echoing the raw anchor; host play/pause/seek keep refreshing anchor+timestamp exactly as before. Every anchor mutation point (setCurrentMedia / updatePlayback / setPlaybackPosition / deserialize) now stamps a millisecond `playback_position_at_ms` alongside the position — a facet OF THE ANCHOR, riding serialize/deserialize so bridge adopts and worker restarts keep advancing; pre-cure blobs self-heal the baseline to the restore instant. Disclosed deviation from the directive formula's letter, within its spirit: the advance is WHOLE-SECOND quantized because the symptom operates at the 2000 ms tolerance scale and a raw-ms advance would move every pre-existing sub-second echo/nudge pin by test-run jitter (a flake class). Both playback_sync emitters advance identically (the group echo AND the S446 nudge unicast — clients seek on any playback_sync frame), while the S446 sync PREDICATE and `drift_ms` keep judging the RAW anchor — the disclosed semantic split preserving the S291-verbatim law. `rate` is a pure-method parameter (default 1.0): no server state or payload carries a playback rate today. No payload keys changed, no new wire codes; contracts registry md5 `40c1da48` untouched; the pre-existing `server_time => $payload['server_time'] ?? time()` fallbacks stay as-is (R-SYNCPLAY-05 adjacent, deliberately NOT expanded). - Red-first proof: new `SyncPlayAnchorAdvanceEchoTest` measured 5E+5F at the parent (rubber-band fingerprint "Failed asserting that 5000 is identical to 35000"), 12/12 green at head; mutation teeth measured — stripping extrapolation at the echo sites reddens exactly the 3 position-path tests, inverting the duration clamp reddens the 2 clamp pins, ignoring the rate reddens the formula pin. Targeted gates: SyncPlay unit suites green unmodified (tracked echo/nudge pins ride the quantization margin byte-identical), phpstan both configs whole-corpus 0 errors, phpcs PSR-12 + repo tests config clean. The ui needs no change: its store already extrapolates anchor+elapsed for its drift predicate and merely seeks to whatever position arrives — arriving advanced is the whole fix. - **The player-facing HLS/DASH pointer endpoints no longer echo the raw `job_id` parameter name (S-01 ask-named residue — W-min109 review MAJOR-1 cure).** - The W-min109 S-01 sweep moved the required-field copy of eight controllers onto the canonical `'<Field> ID is required'` human family, but two ask-named survivors were left undisclosed: `HlsController::getPlaylist()` and `DashController::getManifest()` still answered the empty-ID 400 with `'job_id is required'` — the exact same-field competing-shapes pathology the ask's own evidence cites, on the player-facing manifest-fetch path the admin-surface carve-out does not cover. Both now say `'Job ID is required'`; status codes, JSON keys and envelopes byte-identical — only the human string moved, and the repo-wide grep for the old shape is zero. Forward-correction (this file's own law): the sweep's entry below claimed "All 19 sites now ride the canonical human family" — that universalized over a self-selected site set; these two required-field sites joined the family only now. The reviewer-catalogued wider snake survivors (`media_url`, `hub_token`, and friends) were never ask-evidence sites and stay honest queue-fodder, unchanged by this entry. Estate-wide consumer grep for BOTH string shapes (all clients, ui, hub, contracts, docs, web-ui, openapi) measured zero hits — server-internal envelope prose, no client cascade owed. The two empty-ID 400 tests gained exact-envelope pins (`['error' => 'Job ID is required']` asserted as the whole decoded body): red first measured 2F against the old copy at the parent, green after; the two controller files run 75/281, and the adjacent Transcode/wiring/route-removal sweep holds 36/140 unchanged. Zero new wire codes; the contracts registry md5 stays `40c1da48`. - **Server rider wave A rework — three gate cures from the independent review (D1–D3, forward-fix on the wave).** - **D1 (S126 floor rotation):** wave commit 89d56876 added `SmartRulesRatingRangeRealDbTest` — the 79th real-DB adopter — without rotating `IntegrationDbGuardAdoptionTest::EXPECTED_ADOPTERS`, the same law the guard's own error text states. The rotation 78→79 lands in this forward-fix commit, measured from the phpunit red (failing at 89d56876 through the wave tip and at 4190b62d; the S305 browser-E2E leg failure in the same job was its cascade — junit.xml never produced). - **D2 (psalm-tests corpus):** `LicenseHeaderGuardTest::walkCarriers()` declared a narrowing `\SplFileInfo $current` callback parameter; Psalm models the `RecursiveCallbackFilterIterator` callback's first parameter as the wrapped iterator's union (`RecursiveDirectoryIterator|SplFileInfo|string`) and rejected it with an InvalidArgument — the Coding-Standards red at the wave tip and at 4190b62d. The callback now takes `mixed` behind an `instanceof \SplFileInfo` guard (repo mixed-idiom family); behavior is byte-identical (`SKIP_DOTS` means the value is always the `SplFileInfo` from `RecursiveDirectoryIterator::current()`, so the guard arm is unreachable), proven by the guard suite's 4/10 held and the psalm-tests corpus losing exactly that one error. - **D3 (stale SPA bundle, S253):** the wave flipped the license header in `web-ui/src/i18n/index.ts` but never rebuilt the committed `public/assets/app`, so the Web UI gate diffed the map's embedded source (`index-CPkn_XJ-.js.map`) against the stale committed bytes. The bundle is re-vendored from a clean `npm ci` (npm 11.19.0) on the exact CI-pinned node 24.20.0, determinism proven ×2 (consecutive builds aggregate `7c3055d3d4ff8b71b1511daf9b3052a2`); the tracked delta is exactly one line — the `sourcesContent` header `MIT`→`MPL-2.0`, zero code change. - **Found by the re-vendor venue (nested prune defect in the new guard itself):** with `web-ui/node_modules` on disk — which any bundle-rebuild dev box now has — `walkCarriers()` walked 3,268 vendored paths past the prune list: the original matcher compared the exact repo-relative path, so only top-level `node_modules`/`dist`/`build` sat out of the law, while its header prose claims built and vendored trees are outside it at every depth. Third-party minified headers (`speakingurl.min.js`: `BSD` vs its manifest's `BSD-3-Clause`) then reddened the guard. Pruning is now slash-padded containment per path component, pinned by `testVendoredSubtreesArePrunedAtEveryDepth`; the first-party carrier set is proven unchanged on this tree (removed set = vendored names only, added set empty; walk 2180 first-party carriers before and after). - **Server rider wave A — thirteen silent MIT relapses cured, and M44's "one truth" claim made executable (R-SERVER-11).** - M-SERVER-44's entry stated that after its sweep "every carrier now tells the one truth" (`MPL-2.0`, the license the root `composer.json` has always declared). Thirteen carriers kept declaring MIT: migrations `064, 065, 066, 070, 103, 104, 105` (the tag rides their `--` header banner), `TraktApiRefreshSingleFlightTest.php`, and five web-ui TypeScript files (`src/i18n/index.ts`, `tests/helpers/browser-globals.ts`, `tests/{i18n,i18n-seam,library-scan-route}.test.ts`). They slipped through because M44's promotion touched only PHP under `src/`, `scripts/` and `config/` — and nothing re-checked the claim afterwards. All thirteen now carry `MPL-2.0`; the web-ui edits are comment-header lines only, zero code change. The sample-theme plugin subtree is NOT relabeled: its own `composer.json` declares `MIT`, and honoring nearest-manifest declarations is the law, not overruling them. - The claim is no longer prose: `tests/Unit/Support/LicenseHeaderGuardTest.php` walks every first-party carrier (`php, ts, js, mjs, cjs, vue, sql`; built/vendored trees pruned) and fails if any `@license` tag contradicts the license declared by its nearest ancestor manifest — with a vacuity floor on the walk itself (a matcher that finds nothing cannot pass silently) and a pin that the root manifest says `MPL-2.0`. Mutation tooth proven: planting MIT back into migration `064` reddens the guard naming exactly that file; restored, green. The census gained one PHP file: `EXPECTED_PHP_FILES` re-pinned 2039→2040 from the measured red. - **Server rider wave A — two test-law teeth: re-add freshness and README verb cross-check (R-SERVER-17 + R-SERVER-25).** - **R-SERVER-17 (removed-item re-add freshness pin):** `CollectionItemReAddRealDbTest` covered same-id re-insert against a *present* row and duplicate-guard paths, but nothing pinned what happens when a collection item is removed and later added again — a soft-delete that silently resurrected the original `added_at` stamp (or its position) would have passed every existing test. New real-database case `testRemovedPairReAddsAsFreshRowNotAResurrectedStamp`: seed the sealed member (its historical `SEADED_ADDED_AT` stamp), hard-`DELETE` the pair, assert the row is gone, re-`insert()` with a fresh sort order, and assert exactly one row lands carrying the new `sort_order` and an `added_at` that is *not* the seaded stamp. Green at this tip (8 tests, 48 assertions against the real schema). - **R-SERVER-25 (the route-parity test never read the verb):** `BackupSubsystemReadmeTest::testEveryDocumentedBackupRouteIsReallyRegistered` asserted only that each dataset's *path* appeared as a README substring and that a marker existed in `AdminRoutes` — three evasions: the `{id}` DELETE row was satisfied by the `{id}/restore` POST line's substring, `get-sched`/`put-sched` share one path so a single table row covered both datasets, and the HTTP verb was never cross-checked at all. The test now derives the verb from the source marker (`$r->post|get|put|delete(`) and anchors the README match to a full table row (`| VERB | \`path\` |`, backticked-exact, line-anchored) — provider shape unchanged, no production diff. Mutation tooth proven: deleting the README `DELETE /api/v1/admin/backup/{id}` row line reddens exactly the `delete` data set and nothing else; restored, 10/41 green. - **Server rider wave A — smart-rule rating filters stop putting aggregates in WHERE, and library creation survives a refused watch kick (R-SERVER-19 + R-SERVER-07).** - **R-SERVER-19 (every `rating_range` smart rule was a guaranteed 1111):** `SmartRuleVocabulary::buildRatingRangeClause()` emits `COALESCE(AVG(r.score), 0)` predicates, and `ItemRepository::queryWithSmartRules()` interpolated every rule's `wheres` into the `WHERE` list — MySQL rejects aggregate functions there with error 1111 (`Invalid use of group function`). The clause builder now returns `havings`/`havingBindings` for the rating arm (the `@return` shape carries them); the consumer collects them and (a) appends ` HAVING …` after the existing `GROUP BY media_items.id` on the select leg (bind order: where → having → limit, offset — pinned end-to-end), and (b) on the count leg switches to a derived table (`SELECT COUNT(*) … FROM (SELECT media_items.id … GROUP BY … HAVING …) AS rating_matched`) only when havings are present — unfiltered and non-rating counts keep the byte-identical `COUNT(DISTINCT media_items.id)` path. The order-override `rating` → `rating_sort` mapping finally has a working predicate to ride: `avg_rating DESC` sorting is reachable for the first time. `tests/Integration/Media/SmartRulesRatingRangeRealDbTest.php` (new, `LANE_TOKEN SRVARATINGRANGEHAVINGQ7`) executes the rule end-to-end against a real database: all four cases die with 1111 on the parent (measured) and pass at this tip — min/max survivors, min-only excluding coalesced-unrated items, a combined `decade` + `rating_range` rule pinning the binding order, and the empty-survivors count. `ItemRepositoryPagingLawsTest` gained `testRatingRangeAggregatePredicateRidesHavingNotWhere` (vocabulary `wheres` stay empty; the WHERE leg never sees AVG; select bindings exactly `['lib-1', 7.0, 10.0, 50, 0]`); its header prose — which recorded the arm as "dies today … error 1111" — is flipped to cured narration in this commit (disclosed). The census gained one PHP file: `EXPECTED_PHP_FILES` re-pinned 2038→2039 from the measured red. Zero new wire codes; the contracts registry md5 stays `40c1da48`. - **R-SERVER-07 (orphaned unwatched library behind a 500):** `LibraryManager::createLibrary()` called `$this->watcher->watch($id, $paths)` unguarded after the INSERT, and `FolderWatcher::watch()` runs its initial checksum pass inline — a library over `MAX_WATCHED_FILES` (or an unreadable path) threw straight through the controller as a 500 *after* the row had committed, leaving an orphan that the creation flow believes is watched. The call is now wrapped in a `Throwable` catch that logs a warning and lets the creation succeed; self-heal is already proven elsewhere — `FolderWatchScheduler::syncLibraries()` re-queues unregistered libraries every tick and `registerLibrary()` catches `Throwable` with doubling backoff. `FolderWatcher`'s cap docblock, which named only the scheduler's two guarded entry points, now names this third site. `LibraryManagerTest::testCreateLibrarySurvivesARefusedInitialWatch` pins it (watcher mock throws; insert must still land, id must come back) — red on the parent (measured), green here. - **Server rider wave B — the installer pins its port, the ruleset prose follows shipped CI, and one count is forward-corrected (R-SERVER-22 + R-SERVER-14 + R-SERVER-26 + R-SERVER-30).** - **R-SERVER-22 (custom-port installs proxied into nowhere):** `scripts/install.sh` threaded `--http-port` into the HAProxy backend and its healthcheck but the generated env file never carried `PHLIX_HTTP_PORT` — the Workerman listener (`src/Server/Runtime/ListenEndpoints.php`) fell back to the built-in 8096 behind a proxy dialing the configured port. The env heredoc now writes `PHLIX_HTTP_PORT=${HTTP_PORT}` (one source of truth with the proxy block); `SystemdInstallerEnvTest` gained `testFullInstallerEnvHeredocPinsTheHttpPort`, which scrapes the real heredoc (parser teeth; red on the parent shape — measured). - **R-SERVER-14 (test-guards rule was false on three counts):** `.claude/rules/test-guards.md` claimed the prod CI step was `phpstan analyze src/ --level=9` (reality: bare `./vendor/bin/phpstan analyse --no-progress --error-format=github`, corpus `src/` + `scripts/` and level from `phpstan.neon`), claimed `phpstan-tests.neon` was level 2 (reality: level 4), and claimed `StaticAnalysisScopeTest` asserted the workflow NOT the config (it now asserts both). The bullet is rewritten to shipped truth; the sibling stale "level 2" comment in `coding-standards.yml` is disclosed as residual (outside this row's ask). - **R-SERVER-26 (backup schedule null law):** the README schedule GET row said `next_scheduled_backup` is null "when scheduling is off" — `BackupManager::getNextScheduledBackup()` nulls only on a non-positive interval, while the separate `enabled` toggle gates the automatic timer at registration. The row now states the reader's actual law (its test pin rides the next commit — the target test file is mid-flight in a sibling lane). - **R-SERVER-30 (count forward-correction):** the webhook signing-truth entry below this one says "Five docblocks"; measured from its commit (08136182) the cure rewrote SEVEN docblock/heading sites across FIVE files (WebhookEvent class + its wire-format heading, WebhookService class + its `emit()` example, `WebhookDispatcher::findWebhookById()`, AdminWebhooksController, WebhookAdminController). The historical entry stays verbatim; this note is the correction. - **Server rider wave B — the refresh cap now spares live pending markers, and the sort-order tie law is named (R-SERVER-06 + R-SERVER-18).** - **R-SERVER-06 (Trakt refresh registry evicted across entry kinds):** `TraktApi::boundRefreshRegistry()` picked its victim purely by age, so under cap pressure the oldest live pending marker could die before any finished result — that token's coalescing flight could then POST twice with only flock() Phase 2 between it and a duplicate refresh. Eviction now prefers the oldest FINISHED-result entry and falls back to the oldest pending marker only when every resident entry is live-pending; the hard cap itself stays non-negotiable (the pathological 256-distinct-rotations flood still yields, honestly documented in the class-header comment). `TraktApiRefreshSingleFlightTest` +2: `testEvictionSparesPendingEntriesWhenResultsAreAvailable` (red on the parent shape — measured) and `testEvictionFallsBackToOldestPendingWhenAllPending` (cap-law preservation, green both sides). Registry is process-local; zero wire change. - **R-SERVER-18 (sort_order read-then-mint tie disclosed):** `CollectionItemRepository::getMaxSortOrder()` gained a KNOWN LIMIT docblock naming the concurrent-tie window the review accepted as benign-by-comparison, and pointing at the display-safety law that makes it so: the sole ordered listing query (`findMediaItemIdsForCollection`, what `getCollectionWithItems` builds the detail page from) sorts by `sort_order, added_at`, so a tie resolves deterministically by insertion time. Docblock-only; zero behavior change. - **Server rider wave B — the CLI reset became the single documented escape hatch, and the fiction pins can see wrapped re-entries (R-SERVER-02 + R-SERVER-04).** - **R-SERVER-02 (latch survived every shipped password write):** `bin/phlix user:reset-password` now calls `UserRepository::setMustChangePassword($id, false)` ordered strictly AFTER the password write — an operator-issued reset leaves the account sign-in-capable instead of login/refresh-blocked while printing a working password. README CLI table row and the "Locked by the admin `reset-password` endpoint" story rewritten to the one-step law; the direct-SQL `must_change_password = 0` recovery leg retired from the docs. `AdminUserController::resetPassword()` docblock/body NOTE recovery clauses follow to truth (the pinned phrases "no mailer leg and no reset-completion endpoint" and "the plaintext $token is never sent" hold verbatim — the CLI is not an endpoint). `PasswordResetRecoveryReadmeTest` rotated red-first: the recovery test now scrapes the CLI command for the `setMustChangePassword($id, false)` call-shape and pins the retired SQL sentence OUT of the README; the CLI-row arm asserts "clears must_change_password" (red on the parent row's "does not clear"). `UserResetPasswordCommandTest`: success paths expect the lift, all three failure paths expect none, plus an ordered write-then-lift test. Zero new wire codes, zero migrations — the latch is a column, not the wire; every refusal envelope byte-ident. - **R-SERVER-04 (r5 MINOR-2 pin-blindness survivor):** the anti-fiction loop in `PasswordResetRecoveryReadmeTest` scanned RAW `$methodSource` while the truth-pins flattened — a line-wrapped fiction re-entry ("sent to the user via" wrapped across a comment line) ran green (the review's tip-mutation C reproduced this). The loop now scans the flattened source, with a self-arm pinning `flatten()`'s whitespace-collapse so a future helper neuter reddens directly. Mutation-proven: wrapped fiction planted in the handler body → green at parent, red after cure. - **README static-analysis runbook, the SyncPlay edge-transport notes and the docker port-table caption lagged their shipped reality (server rider wave A, rows R-SERVER-13/23/29/31).** - **R-SERVER-13:** the README "Run static analysis" block prescribed `./vendor/bin/phpstan analyze src/ --level=9` — the exact CLI-override prescription AGENTS.md forbids (a command-line path overrides `parameters.paths` whole; since S256-residual the production step carries NO path and NO level). It now shows the canonical pair `./vendor/bin/phpstan analyse` + `./vendor/bin/phpstan analyse -c phpstan-tests.neon`. `StaticAnalysisScopeTest` pins the WORKFLOW and the configs — no README assertion exists, so the runbook was the last carrier of the forbidden shape. - **R-SERVER-23:** reverse-proxy/README.md's "Future note" (and its sibling comment inside `docker/examples/full-stack/docker-compose.yml` — disclosed same-commit edit, same stale sentence) described the `@phlix/ui` `wsUrl` seam as "being designed (sibling lane)". That seam shipped: `wsUrl`/ `wsPath` in `@phlix/ui` and `HUB_WS_URL`/`HUB_WS_PATH` on the hub are live. What still has NOT shipped is same-origin `/ws` upgrade routing against :8096 — both notes now say the shipped truth and KEEP the do-not-add-`/ws` warning, which remains correct. - **R-SERVER-29:** tracked `phpunit.xml.bak` (1,847 bytes, added by `59bc76c3`, zero references repo-wide) is removed. - **R-SERVER-31:** docker/README.md's port table claimed "exactly what `start.php` binds" — `PHLIX_LISTEN_ENDPOINTS` overrides exist; the caption reads "what `start.php` binds by default". - Docs/hygiene only: zero behavior change, zero new wire codes (registry md5 40c1da48 held); census inert (no PHP-file movement — a `.bak` is not PHP). `DockerEntrypointTest`, `ImagePinDriftGuardTest` and `StaticAnalysisScopeTest` — the suites that read the touched files — re-run green at this commit. - **Three CHANGELOG provenance lines told their lane-base truth and went stale at landing; disclosed forward, histories left verbatim (server rider wave A, rows R-SERVER-03/09/27).** - **R-SERVER-03 (in "The reset-password recovery story is now fully shipped-truth per leg", landed `849f1d1c`):** the entry asserts census `EXPECTED_PHP_FILES` "stays 1995 unmodified". True at the lane base (`3282386e`, pin 1995); stale at landing — three sibling rungs advanced the pin while the branch queued: `c4c9bbd8` →1996, `45a69943` →1997, `b0fd54cf` →1998, every figure ancestor-measured (`git show <sha>:tests/Unit/Server/Http/RequestDynamicPropertyCensusExecutableTest.php`) in this correction's own window. The zero-PHP-delta claim itself holds (parent `b0fd54cf` 1998 == landing 1998); only the "stays 1995" figure described the base rather than the landed tip. - **R-SERVER-09 (in "Browser auth errors/notices no longer vanish in the legacy redirect chain", landed `45a69943`):** the entry claims "PHP files 1995→1996 atop the M38 rung" — a pre-rebase figure. Measured at landing: parent `c4c9bbd8` (M37) pin 1996, landing pin **1997**; the rung rotated 1996→1997 atop the M37 rung. The in-code census staircase docblock already carried the corrected ladder; only this CHANGELOG line lagged. - **R-SERVER-27 (in "README documents the backup subsystem to shipped-code truth", landed `82d9f029`):** the entry closes "green at this tip." The new backup suite was green; the full suite was not — the S431 census rung missed its rotation at that commit (pin 1987 against 1988 real PHP files), forward-fixed one commit later by `894ede3e` ("census pin forward-fix (1987->1988 missed at 82d9f029)"). The qualifier the line never carried: "green" named the section's own suite, not the whole battery. - Docs-only correction: zero behavior change, zero new wire codes (registry md5 40c1da48 held), census/S180/adopters inert. - **A literal `null` Origin can no longer coast through the CSRF gate (server minors batch W-min109, part 6 — M-SERVER-76 cured).** - **M-SERVER-76 (cured):** `RequestAuthenticator::validateCsrfOrigin()` exempted `$origin === 'null'` from its origin leg — disk truth first: the Referer leg still ran, so the ask's "skips the check entirely" overstates, but the real hole was exactly one shape wide: `Origin: null` with NO Referer reached the accept tail UNOPPOSED for POST/PUT/DELETE/PATCH. Literal `null` is the opaque-origin marker some browsers send (sandboxed iframes); it cannot name an origin, so it now refuses outright, before either leg, for state-changing methods — closing both the no-Referer bypass and the matching-Referer pass-through. Envelope byte-ident: the bool feeds the unchanged 403 `csrf.invalid_origin` gate, zero new wire codes. Disclosed: sandboxed-iframe same-site writes now fail closed (the honest tightening the ask named — `prefer ldaps…or at minimum warn`-class minimum was NOT the shape here; this is the named cure); EMPTY-string Origin deliberately keeps its skip (ask names only `null`). `RequestAuthenticatorTest` case-9 rewritten to three new-law pins (null+matching-Referer refused, null-no-Referer refused, null+mismatched-Referer stays refused) — measured parent red 30/40/2F, green 30/40 post-cure; AuthController/SessionController consumers + full tests/Unit/Server/Workerman/ 13-file sweep (the real-gate CSRF arm rides evil-https origins, unaffected) all OK; census/S180/adopters inert (2038/455/1118 held — helper seeds untouched, `return false` arms are not throws). - **An uncaught crash on `/api/*` now answers JSON clients with a parseable 500 (server minors batch W-min109, part 5 — M-SERVER-53 cured).** - **M-SERVER-53 (cured):** the resident worker's last-resort `catch (Throwable)` hand-built ONE reply shape for every path — the full M-SERVER-87 HTML document — so a fetch()-based SPA or API client asking `/api/v1/...` caught a wall of markup where its `response.json()` had nothing to parse (statuses matched, bodies didn't). The catch now branches on the REQUEST PATH ONLY: `/api/`-prefixed requests receive the generic machine envelope `{"error":"Internal server error"}` (`application/json; charset=utf-8`) — the same `{error: …}` shape the router's own refusal helpers ship, ZERO new wire codes, a pre-baked string constant (no encoder call on a crash path) — while every other surface keeps the browsable document. Protocol behavior is deliberately untouched across both arms: S113's headAware strip, the strlen-derived Content-Length the HEAD advertises, and the CORS/security decoration ride whichever entity was picked. `HttpHandlerHeadNoBodyTest` site-4 (the wire-level HEAD/GET law) was the pin encoding the old shape against `/api/v1/anything` — flipped red-first (measured 9/119/2F at the parent) and now pins BOTH arms: the JSON envelope on the API path and, newly, the HTML document on a non-API crash (`/crash-page`), so neither branch can silently swallow the other. ErrorPages' site-guard (`serverErrorHtml()` exactly one call site) holds — the document is still reached from precisely one place, now through the branch. Full tests/Unit/Server/Workerman/ 13-file sweep + ErrorPagesTest green; census/S180/adopters inert (content edits + one new const, zero Request-member movement, zero throws added — `json_encode` was deliberately avoided for the constant after phpstan L9 flagged its `string|false`); phpstan L9/L2 + psalm both venues clean; disclosed: the one >120-char line phpcs shows in the test file pre-dates this change (HEAD count 1) and the tests standard tolerates warnings, so it rode through. - **Per-worker memoization for the two hottest request-time file/env reads (server minors batch W-min109, part 4 — M-SERVER-33 + M-SERVER-40 cured).** - **M-SERVER-33 (cured):** `ViteAssets::getEntryJsPath()` ran `file_exists` + `file_get_contents` + `json_decode` of the Vite manifest on EVERY hosted-web request (`SharedUiController` constructs it per request), re-parsing identical bytes thousands of times per worker-day. It now memoizes per worker keyed on the manifest path, holding the parse while a `(mtime:size)` fingerprint stands and re-reading the instant the fingerprint moves — deployment replacement busts the memo naturally, no invalidation choreography, and the first-boot/removed-file failure shapes (the same `RuntimeException` copy telling operators to build the SPA) are byte-identical. The read path gained `clearstatcache(true, $path)` INSIDE the fingerprint check: PHP's own stat cache otherwise serves stale `filemtime`/`filesize` after a same-size replacement — the very deploy the memo must notice (same law the subtitle cache path cites). `resetParsedForTesting()` gives tests the per-worker fresh-start seam. - **M-SERVER-40 (cured):** `HttpHandler` called `CorsManager::fromEnv()` twice per request (static arm + rate-limit arm), each rebuilding the instance and re-splitting `PHLIX_CORS_ALLOWED_ORIGINS`. The factory now consults a bounded per-worker memo keyed on the RAW env value: steady state hands back ONE immutable instance; a `setenv` changes the key, so a changed value COLD-PARSES rather than serving a stale allow-list — the env-key design deliberately forgoes the blind singleton so test-suites that rotate `PHLIX_CORS_ALLOWED_ORIGINS` (the fast-path decoration suite does, per-request) can never read a cross-test value; `FROM_ENV_MEMO_CAP` (8) evicts so churn-heavy suites don't grow it. `resetFromEnvMemoForTesting()` seams the memo for tests. - Method: both memo pins were written AFTER the cures (an ordering deviation) — the honest parent-red was recovered by STASHING the two src files and replaying: the new suites died with undefined-seam ERRORs at the parent (4+3 errors, disclosed as the ERROR class they are), green after restore (`ViteAssetsMemoTest` 4/7, `CorsManagerFromEnvMemoTest` 3/7). One genuine bug surfaced during the pin loop: the fingerprint memo initially never saw the test's `touch()`ed change (PHP stat cache) — the fingerprint-bust pin went RED against the cured code and `clearstatcache` is the fix, pinned by `testAFingerprintMoveBustsTheMemoLikeARealDeploy`. Regression: full WebPortal (18 files) + Middleware + Workerman (13 files incl FastPathDecoration/Compression/ServeStatic/HeadNoBody) sweeps green; census denominators moved from measured reds ONLY (files 2036→2038, reads 453→455, writes 1117→1118 — the two new suites' seeds, attributed in staircase rungs); S180 unrotated (zero new throws); adopters 78 hold; phpstan L9/L2 (one same-call-pair assertSame reworked to locals — the d2685e2b family), psalm both venues, phpcs both standards clean. - **Missing-parameter refusals across the API now speak one human family, and hub-enrolment says the same sentence everywhere (server minors batch W-min109, part 3 — M-SERVER-01 + M-SERVER-09 cured).** - **M-SERVER-01 (cured):** the required-field copy had drifted into ten shapes across 8 controllers ('library_id is required' vs 'Library id is required', 'media id' vs 'media_id', 'collection id', 'job id', and one two-grammar sentence 'album id and library_id are required'). All 19 sites now ride the canonical human family this vein set at S-02/W-min101 — '<Field> ID is required', period-free: MediaItemController (:281-era, :893-era), PhotoController (four 'library_id' arms + the album/library compound → 'Album ID and Library ID are required'), CollectionController (four legs + the two 'library_id' arms), TranscodeController (media + job), AdminMergeController (library + the ADMIN-surface 'primary_id' arm — humanized to 'Primary ID is required' under the same law; disclosed: the ask permitted keeping param names on admin-only surfaces, but two shapes of one family is the drift this cure exists to end), CollectionController\'s lone ':331-era' arm matching the WebPortalRouter SSOT. ThemeMediaController and the WebPortalRouter rails were ALREADY compliant (verified; untouched). Status codes, keys and envelopes byte-identical — only the human string moved. Red-first: 6 test pins flipped first measured 2+1+3 failures at the parent, green after. Disclosed: docs/openapi grep for every old string = zero mentions (no doc collateral). - **M-SERVER-09 (cured):** hub-enrolment refusal was phrased three ways ('Not enrolled in hub' in the network-health error slot, 'Server is not enrolled with a hub' in two message slots). One sentence now: "This server isn\'t enrolled with a Phlix Hub yet. Enrol it from Admin → Hub." (periods kept — multi-sentence under the S-04 law). Machine arms untouched: the `hub.not_enrolled` code pins in HubTokenControllerTest/AccountLinkControllerTest rode through unchanged, statuses identical, HealthController\'s structured array shape preserved. Honesty notes: an apostrophe left unescaped in the first draft died php -l mid-step (caught, re-escaped), a repair pass double-mojibaked the arrow (re-repaired via re.sub), and the two resulting 121-char test lines were single-sourced through a private const ENROLLED_COPY — the canonical sentence now lives in exactly one place per file (awk>120 zero). Health 13/78, ShufflePlay 20/52, Collection 26/85 green; five controller regression suites + the Admin dir sweep green; census/S180/adopters inert by design (content-only edits, no new files, zero throws) and re-verified green unrotated. - **The stale-session sweep now reports exactly the sessions it deleted (server minors batch W-min109, part 2 — M-SERVER-85 cured).** - **M-SERVER-85 (cured):** `SessionManager::cleanupStaleSessions()` harvested ids under one `DATE_SUB(NOW(), …)` evaluation, then deleted under a SECOND one — `NOW()` advances between the statements, so a session that went stale in the gap was deleted but never counted: the reported (and logged) cleanup number could UNDERSTATE the truth. The sweep now follows the estate two-phase law the StatsCollector reaper already documents (R19-10/hub ServerReaper): harvest ids with the single DB-clock cutoff (the R19-03 clock-unification law is untouched — the cutoff stays DB-computed), then `DELETE … WHERE id IN (placeholders)` riding exactly those ids. The understatement class is killed BY CONSTRUCTION (count == the id set == the deleted set) and the unbounded-range DELETE — a gap-lock shape under concurrency — is gone as a side-benefit. `SessionManagerCleanupCountTest` (2 pins) proves the statement shape, the param binding, that `NOW()` is evaluated ONCE across the flow, and that an empty harvest issues no DELETE; measured red 1 failure at the parent, green 2/8 post-cure; the existing real-DB consumer `DbRemediationRealDbTest` rode through 10/90. Self-caught mid-step: a first draft of the DELETE-shape pin normalized whitespace in the haystack (str_replace) making the spaced needle unmatchable — fixed to compare the RAW sql; the parent red was genuine either way (no IN-list existed). The census denominator moved 2035→2036 from its measured red (+1 test file; the harness mocks the Connection — zero Request reads/writes), S180 unrotated (zero new throws), adopters 78 held (unit-mock file). Disclosed: a row going stale between the two statements now rides to the NEXT sweep instead of being deleted-uncounted — the count never lies, and the next sweep (same predicate, single evaluation) still collects it. - **The Roku mDNS service type loses its stray space — discovery can now actually see real Rokus (server minors batch W-min109, part 1 — M-SERVER-82 cured).** - **M-SERVER-82 (cured):** `MdnsDiscovery::SERVICE_ROKU` shipped as `'_ roku-ecnp._tcp.local.'` — a space between the underscore and `roku-ecnp`, which RFC 6335/6763 labels do not allow and which none of its siblings suffer (`_googlecast._tcp.local.`, `_airplay._tcp.local.`, `_raop._tcp.local.` are all correctly spelled; the Roku const was the family's only outlier). The consequence is bigger than the display regex the review named: the PTR QUERY itself asks for a service type no real Roku advertises, and the `._ roku-ecnp._tcp.local.` suffix-stripping regex in `RokuDiscovery::mdnsServiceToDevice()` can never match a real instance name — every discovered device would have kept its raw suffix. The ask scoped the fix to the regex; the whole chain (const + query + strip + the two docblocks that DOCUMENTED the space as intentional) is the same one defect, so all of it is corrected in one stroke. `RokuDiscoveryTest` fixtures move to real advertisement shapes and gain the strip-law pin the suite never had ('Family Room._roku-ecnp._tcp.local.' → 'Family Room'); `MdnsDiscoveryTest`'s constant pin flips to the corrected spelling. Measured from red: 1 failure per file at the parent (strip pin + constant pin), green 4/10 + 12/21 after. Disclosed: only the server repo was touched and audited — other estate repos advertising the same typo would be their own items (not checked here; the server is the sole Roku-discovery implementor in-tree). Census/S180 inert (no new files, zero throws). - **An LDAP settings save no longer stays silent about its plaintext bind (server minors batch W-min108, part 7 — M-SERVER-78 cured).** - **M-SERVER-78 (cured):** the review's own stated minimum — "prefer ldaps/start_tls automatically on port 389, OR AT MINIMUM WARN ON SAVE" — shipped as the warn arm: an LDAP admin save that lands with `ssl=false` (absent-or-false; the S117 wholesale-replace merge means an omitted key preserves a stored true) now writes a server-side `error_log` notice naming the UNENCRYPTED bind posture and the port, pointing at ldaps (ssl=true, typically 636). The ask's ALTERNATIVE — flipping `use_tls`/auto-TLS — is deliberately NOT shipped: it silently changes connection semantics for every existing deployment (consumer-visible breaking); it is filed here by quote instead. Envelope byte-ident (success json untouched, zero wire states), and no connection code moved: `LdapConnection::connectionConfig()` still derives `'use_tls' => !$this->ssl` — verified the file's only use_tls site, matching the ask's "diagnostic-only" premise. Coordinates drifted in the evidence (Controller :320-era → `saveSettings()` at :109; LdapConnection :204 → the filter region) — re-grounded at tip before editing. `LdapAdminControllerPlaintextWarnTest` 5 pins over the real controller+plugin on an in-memory store with the error_log redirected to a temp file: two warn arms measured RED at the parent (5/15/2F — log empty), three controls (encrypted silent, preserved-encryption silent across a second save, rejected save neither warns nor stores) green on both codes; post-cure 5/16. Census rungs from their measured reds (files 2034→2035, writes 1116→1117 — one shared `post()` seed site; reads held: the response-body reads are not Request roots); S180 unrotated (error_log is not a throw). Disclosed: docs carry no pre-existing LDAP TLS guidance — this warning is now the only operator-facing signal on this surface; a README row stays a separate call. - **Recording start re-validates the stream URL, closing the tune-then-spawn SSRF window (server minors batch W-min108, part 6 — M-SERVER-81 cured).** - **M-SERVER-81 (cured):** the IPTV pipeline vetted stream URLs at playlist-parse and tune time (M3UParser, IptvTunerDriver via `StreamUrlGuard`), but `Recorder::startRecording()` then spawned ffmpeg against the STORED/resolved URL later — DNS can rebind between validation and use, and the public `?string $streamUrl` entry let any caller hand in a URL that was never validated at all. The recorder now validates AT THE MOMENT OF USE, immediately before the spawn: `StreamUrlGuard::refusalReason()` (chosen over `assertTunable` because it never throws — the file gains zero new throw sites, S180 inert) applies the always-on jail (scheme, metadata endpoint, loopback/link-local literals — refused in both postures) plus the operator-configured strict leg (`iptv.strict_stream_policy`, the same flag the tuner driver honors, now threaded to the Recorder as a defaulted 9th ctor parameter fed by `LiveTvServicesProvider` through the factory's own `is_array` narrowing idiom). A refused recording takes the existing SV-3.1a no-fake-PID shape — warning log with the refusal reason, `STATUS_FAILED` + 'Stream URL refused at recording start' in the existing `error_message` column, no pid, no spawn — zero new wire states. `RecorderStartGuardTest` 6 pins (file:// / 169.254 / loopback refusals, the strict-only RFC1918 discriminator, two spawn-controls proving the guard never eats legitimate starts): measured red 4 failures at the parent, green 6/29; the whole-file double reaches the spawn seam only through the `launchDetached` interception (RecorderSpawnLogTest precedent, ArrayObject sink — no real process, no cross-tool intersection-type gymnastics). Full tests/Unit/LiveTv (incl Recording/ + Tuners/Iptv/) suites rode through green (two CoroutineFork files skip host-side: no ext-swoole, by design); census measured-red rung files 2033→2034 (reads/writes held — the harness drives Connection/Logger mocks, never Request); S180 17/45 UNROTATED; phpstan L9/L2 + psalm (both venues; the src-corpus Swoole UndefinedClass lines at Recorder.php:1386-1387 are the documented swoole-less-image noise, outside every hunk) + phpcs both standards clean. Disclosed: `RecorderTest`-suite URLs all pass the jail by construction (green unmodified); the strict leg keeps the operator default OFF exactly like the driver — the fix closes the rebind/none-validation windows without changing the network posture anyone opted into. - **A rotated IdP signing key no longer strands OIDC logins behind a 24-hour JWKS cache (server minors batch W-min108, part 5 — M-SERVER-39 cured).** - **M-SERVER-39 (cured):** `IdTokenValidator::fetchJwks()` served its static per-provider cache for 86400 s with no key-id awareness (zero `kid` hits in the file), so the moment an identity provider rotated its signing key every login failed with the generic copy until the day-old entry aged out. The validator now treats an unknown `kid` in a token's protected header as the ROTATION SIGNAL: it force-refetches — at most one network attempt per `KID_MISS_REFETCH_MIN_SECONDS` (60) window, stamped BEFORE the request so a failing IdP costs one retry per window rather than one per token — and adopts the fresh set only when it actually carries the awaited kid. Every degradation path (throttled, failed, empty, absent kid, header unreadable) keeps the HISTORICAL generic `Token validation failed:`-family copy: zero new wire states, and a token with no `kid` at all never triggers refetch (proven control). `fetchJwks` gained the defaulted `$force` parameter, the ctor an optional injected `OidcHttpClient` (so the refetch rides the same client/test seam; `OidcProvider` now passes its own), and the L9 denominators their staircase rungs (files 2032→2033; reads/writes held — the harness drives the HTTP seam, never Request). `IdTokenValidatorKidRefetchTest` 4 pins (forced-bypass red 1≠2 at the parent, cache-serve control, one-throttled-refetch + rotation recovery proven by NETWORK OBSERVATION, absent-kid willNever-get) — parent measured 2 failures, green 4/16; full tests/Unit/Plugins/Oidc/ 12-file sweep OK; phpstan L9/L2, psalm (both venues), phpcs both clean after reworking the mixed-slot reads behind is_array/is-numeric guards (phpstan L9 caught the direct-offset drafts) and routing the second throttle assert through a helper (assertSame-through-narrowing class, d2685e2b family). Disclosed: mid-step the empty-JWKSet parent-behavior surfaced (vendor JWSVerifier throws 'There is no key in the key set' before any comparison) — pins seed a one-key set so the historical copy asserted is the real shipped one. - **A stale `If-Range` validator no longer splices partial bytes out of changed files (server minors batch W-min108, part 4 — M-SERVER-48 cured).** - **M-SERVER-48 (cured):** both range-serving legs honored `Range` unconditionally — an `If-Range` value the file no longer matched (the exact Last-Modified string Workerman emits at send) still produced a 206 window (or a 416) out of bytes the client had never seen; RFC 7233 §3.2 instead REQUIRES the range be ignored and the full 200 representation sent. Now: `TranscodeFileServer::serveJobFile()` (the trait HlsController/DashController/LiveTvStreamController mix in) and `PreRouterFastPaths::serveMediaStatic()` each compare a present, non-empty `If-Range` against the current validator and fail CLOSED on an unverifiable mtime — stale ⇒ the whole Range branch is skipped, unsatisfiable included (full 200, not 416). Absent/empty `If-Range` keeps the historical 206/416 behavior byte-identically; the S113 HEAD arms run before and are untouched; zero new wire codes or copy. Disclosed: for `no-cache` playlist files the validator is now also computed for the If-Range comparison (the 304 arm still skips them), so a matching If-Range on a playlist honors its window. New `TranscodeFileServerIfRangeTest` (5/11, anonymous trait host — measured red 3 stale arms at the parent; controls green both codes) + two appended `PreRouterFastPathsMediaStreamTest` pins (red 1 → 20/44 green). FastPath + Workerman sweeps and all three trait-host suites green; census rungs files 2031→2032, reads 450→453 (three harness `$request->headers[...]` seed sites; the cure reads via `getHeader()` — inert to the denominator), writes 1115→1116 (`userId` seed), all from measured reds with staircase attribution; S180 17/45 UNROTATED (zero new throws); adopters 78 hold. - **WebAuthn ceremony failures stop rendering parser jargon on the wire (server minors batch W-min108, part 3 — M-SERVER-06 cured).** - **M-SERVER-06 (cured):** all four ceremony catch arms in `WebAuthnController` (`startRegistration` :211, `finishRegistration` :257, `startAuthentication` :294, `finishAuthentication` :342) echoed `$e->getMessage()` verbatim — and the manager's 33-string throw inventory is parser diagnostics ('rpId hash mismatch', 'Malformed COSE public key', 'CBOR nesting too deep') plus account-state oracles ('User not found', 'No credentials registered for user') that leaked through the start-auth arm as an existence probe. The arms now route through one private `ceremonyFailure()`: a phase-appropriate user sentence (the reviewer's exact copy for finish-authentication) while the raw diagnostic moves to `error_log('webauthn: <phase> failed: …')` for the operator. Envelope contract law held byte-identically: bare `{error}` shape, no fields added, historical statuses (400/400/400/401), zero new wire codes; the manager and its `WebAuthnCeremonyTest` 17/55 pin ride untouched. Disclosed: the `$result->error ?? 'Authentication failed'` path inside finish-authentication is a RESULT-object channel, not an exception arm — deliberately out of scope. New `WebAuthnControllerFailureCopyTest` (5/18) measures red 5 arms at the parent, green after; the whole `tests/Unit/Auth/WebAuthn/` family (8 files) sweeps green. Census re-pinned from BOTH measured reds (files 2030→2031, declared writes 1107→1115 = the harness seeds, sanctioned pattern); S180 17/45 unrotated (`error_log` is not a throw); psalm tests venue + phpstan both configs + phpcs both standards clean. Erratum: the report row briefly carried drifted coordinates :217/:264/:301/:352 from a phantom tool read; the disk arms are :211/:257/:294/:342 as the original ask stated. - **The collection delete is now one transaction, and the 080 legacy script stops advertising a manual run (server minors batch W-min108, part 2 — M-SERVER-17 + M-SERVER-19 cured).** - **M-SERVER-19 (cured):** `CollectionManager::delete()` ran `deleteAllForCollection()` then `delete()` as two autocommit legs — a crash or failure between them left an item-stripped collection row. The manager now holds the shared `Workerman\MySQL\Connection` (required 6th ctor param; container autowire and the Application fallback both hand it the SAME instance its repos already use — identity law) and wraps both legs BEGIN→items→collection→COMMIT with ROLLBACK + unchanged re-raise on failure, mirroring the `ItemRepository::create()` shape. Migration 122's FK CASCADE makes the delete ORDER forgiving; the explicit ordered legs stay for determinism and pre-FK restore backstops — the transaction is what makes atomicity. Red-first measured: two recording pins (order vector; rollback-and-rethrow) ran 2 failures at the parent (extra ctor arg is a userland no-op pre-cure), green 17/25 after; all 15 existing unit constructions + the two integration helpers gained the `$db` 6th arg. Consumers green: CollectionCrudTest, CollectionItemReAddRealDbTest, SmartPlaylistRefresh trio (10/31, 3/13, 4/9), ApplicationRouterWirePathGuardTest 14/5631, MigrationRunnerTest 86/255 (080 stamp inert), census UNCHANGED 2030/1107, S180 gate 17/45 unrotated (the rethrow is the same reached-by-tests class as S-21), phpstan prod-L9 + tests whole-corpus-locally [OK], psalm tests venue exit 0 (16 non-gating infos), phpcs both standards 0. - **M-SERVER-17 (cured):** `migrations/080_backfill_missing_profiles.php` still commanded "Run this ONCE after migration 079" although the runner discovers `*.sql` only — the chain-applied `.sql` twin has done the job on every install since 096-era. The PHP header now carries a SUPERSEDED banner (script retained as the manual merge-duplicates diagnostic; the example invocation line re-stamped as diagnostic-only; the checksum law keeps the `.sql` untouched). `php -l` clean; no behavior changed anywhere (runner never saw this file). - **One duplicate-email rejection sentence now answers every surface (server minors batch W-min108, part 1 — M-SERVER-03 cured).** - **M-SERVER-03 (cured):** the same rejection — "that email belongs to someone else" — was phrased four user-facing ways (AuthManager `Email already registered` rendered straight into the registration 400; AdminUserController `Email already exists` on create and `Email already in use` on update, each additionally carrying the fifth variant `This email is already registered` in the `field_errors.email` slot; the admin CLI said `Email already registered: <email>`). All six strings now read the one canonical sentence `That email address is already in use` — deliberately period-free per this estate's single-sentence copy law (minors M-SERVER-04, W-min101), a disclosed deviation from the review's period-carrying proposal; the console keeps its operator-context `: <email>` tail on the shared stem. Status codes, envelope shapes, the `field_errors` map keys and the `InvalidArgumentException` machine path are untouched — zero new wire codes; the `UserRepository::emailExists()` docblock sentence describing the CHECK (not a user-facing rejection) stays as-is. Pins moved with the copy: `AdminUserControllerTest` create/update arms (assertSame) and `UserCreateCommandTest` display arm — measured RED at parent exactly 2+1 failures, green after the source flip (40/128 + 8/28); `AuthManagerSignupGateTest` 25/58 and `PasswordPolicyEnforcementTest` 5/13 rode through unchanged. Docs-only-adjacent: one docblock `@throws` example re-anchored; `UserCreateCommand` fail-call reflowed multi-line for the no-`-n` PSR-12 src leg (a 121-char single line was caught locally before push — the CI src gate has zero warning tolerance). Census re-ran green UNCHANGED 2030/1107 (content edits, no new files); S180 17/45 UNROTATED (existing throw, message text only). - **Usernames and library names are now measured in characters and trim-gated (server minors batch W-min107, part 4 — M-SERVER-86 cured).** - **M-SERVER-86 (cured):** `AuthManager::register()` guarded `strlen($username) < 3 || strlen($username) > 50` while its own message promised "3-50 characters" — bytes voted (a 17-character CJK name is 51 bytes and was falsely refused), and `" "` passed both that guard and the controller's `!== ''` pre-gate, minting ghost accounts that render blank in every user list. `LibraryController::create()` kept the same shape for library names (`$name !== ''`, no trim). The estate law already existed at `CollectionController` (trim-equals-empty rejects); it is now uniform: register trims first, then `mb_strlen` bounds (internal spaces stay — trim-only, never strip-all; deliberately NO charset whitelist added — Unicode names are legitimate and a charset rule is a broader change than the named defects; the documented rule lives in the @param). Library create trims the string arm before the existing validity conjunction, so the canonical 400 sentence is unchanged and `createLibrary` receives the trimmed name. `RegisterUsernameLawsTest` (7/17: ghost rejection, true 17-CJK and 50-CJK acceptances via the sentinel, 51-CJK ceiling, probe-sees-trimmed-form, internal-space + floor controls) measured red exactly 4 arms at the parent; LibraryControllerTest's two new pins measured red 2 at the parent, full file green 80/281; signup/password/paths-redaction siblings held (25/58, 5/13, 5/25). Disclosed: `LibraryController::update()` carries the same untrimmed-name pass-through — outside the ask's named sites, left for its own item; zero new wire codes or copy (the existing InvalidArgumentException/400 envelopes carry the truth); census re-pinned 2029→2030 (new law test) and writes 1103→1107 (the two new controller pins' sanctioned harness seeds) from measured reds; S180 unrotated (no new throw sites). - **A production boot with no database password is no longer silent (server minors batch W-min107, part 3 — M-SERVER-38 cured).** - **M-SERVER-38 (cured):** `config/database.php` resolved `DB_PASSWORD` through `EnvConfig::text(..., '')`, so a systemd/bare-metal install missing the env line booted happily against an empty-credential MySQL user if the DB allowed it — while the container path fail-closes on placeholders. The ask's own two offered shapes now BOTH ship, tiered: with `PHLIX_ENV=production` (the shipped unit default), an UNSET `DB_PASSWORD` logs exactly one loud `E_USER_WARNING` at boot naming the exposure and the remedy (dev/test tiers and explicit-`''` operator acts stay silent — the empty default remains a deliberate local choice; warn-once because direct consumers re-include the config file per worker and would otherwise re-warn). And the new opt-in `PHLIX_REQUIRE_DB_CREDENTIALS` knob (default `0`, resolved through the file's own `=== false ? '0' : getenv()` + `filter_var` idiom so explicit `'0'` cannot arm it — the `pool_enabled` neighbour law, inverted default) turns missing-OR-empty credentials into a hard boot refusal, mirroring `PHLIX_MIGRATIONS_STRICT`'s opt-in strictness. Census rows added for the knob and the `DB_PASSWORD`/`PHLIX_ENV` lines extended; README remains the definitive env census. New `DatabaseConfigCredentialGuardTest` (8 tests, latch-seamed boots): measured red at the parent = exactly the 4 production arms (warn, two refusals, knob-zero-still-warns) with the 4 silence/untouched controls green on both codes. Disclosed: the dispatching brief labeled this item part 2 — S-49 had already consumed that label at `378caf5a` (ask-text reconciliation on file in the fixlog), so this lands as part 3 chronologically. phpstan/psalm corpora cover `src`+`scripts` only (scope asserted by `StaticAnalysisScopeTest`) — the `config/` file is checked by `php -l`, PSR12 ad-hoc (clean), census rung 2028→2029 from its measured red (writes hold 1103: the guard reads `getenv`, never a Request), S180 17/45 green unrotated (the throw is REACHED by the new tests — no baseline entry needed). - **The `immutable` year-tag on the SPA bundle tree now requires the content hash (server minors batch W-min107, part 2 — M-SERVER-49 cured).** - **M-SERVER-49 (cured):** `HttpHandler::serveStatic()` tagged EVERYTHING under the realpath-jailed `/assets/app/` prefix with `Cache-Control: public, max-age=31536000, immutable` — but the tree also carries unhashed residents: the raw SPA shell `index.html` (reachable via the side-door URL; it ships WITHOUT the `window.__PHLIX__` injection the canonical `/app` route performs), `.vite/manifest.json`, and publicDir copies. A browser that ever hit those URLs directly pinned pre-injection bytes for a year — a stale-bundle boot trap on the next upgrade. The gate now rides the Vite naming law itself (one `-<8-char url-safe hash>` segment before the final extension, extension-agnostic so hashed fonts keep the tag); anything not hash-shaped fails CLOSED to the house `no-cache` revalidation default (the same value `Response::html` seeds). `HttpHandlerServeStaticTest` gained four pins — the shell, the manifest, a short-suffix/doubled-extension fail-closed pair, and a hashed woff2 that keeps immutable (control); measured red 3 failures at the parent, green after. The two pre-existing wire-level immutable pins ride hashed seeds (`index-DaB12cd3.js`, `big-Ab12Cd34.js`) and held UNCHANGED; `NginxAssetLocationTest`'s upstream-authority string pin still finds the literal tag in the handler (the branch, not the value, changed). Disclosed: the dispatching brief cited this cure under the label S-38 — the disk ask text names it M-SERVER-49 (batch label W-min106 in the queue); the DB_PASSWORD item (my ledger's S-38) remains owed. Zero new files, zero new throws, zero Request-property writes — census/S180/adopters re-ran green unrotated. - **The agent-docs and the environment census now tell the shipped schema and shell truth (server minors batch W-min107, part 1 — M-SERVER-14, M-SERVER-15, M-SERVER-25 cured).** - **M-SERVER-14 (cured):** AGENTS.md and CLAUDE.md still described the pre-096 world for `(library_id, path_hash)` — the UNIQUE index as "added out-of-band by `migrations/cleanup_072.php`". It has been IN-CHAIN since `migrations/096_path_hash_unique_index.sql` (idempotent; it refuses to touch a duplicate-bearing table and names the remedy instead), and cleanup_072 "now owns de-duplication only" — the README block and the src docblocks (`MusicScanSkipIndex`) already said so. Both clauses reworded to mirror them. Disclosed: AGENTS/CLAUDE are caliber-managed agent-docs; this is the fact-fix the ask names, not a caliber-refresh revert. - **M-SERVER-15 (cured):** "All PKs are CHAR(36) UUIDs" was false estate-wide — AUTO_INCREMENT `INT`/`BIGINT` keys appear from 052 onward (`metadata_ratings`, `media_markers`, `webhook_events`), `plugin_settings` is VARCHAR-keyed (093), and `user_item_data`/`collection_items` carry composite PKs (100/005). Both files now scope the UUID law to the 001/002 identity lineage and tell the reader to check the migration; CLAUDE.md's `generateUuid()`-reuse guidance stays — it remains the right answer for identity-style keys. - **M-SERVER-25 (cured):** the "definitive census" listed `PHLIX_SECRET_KEY` and `PHLIX_MIGRATIONS_STRICT` as docker-entrypoint-only but not the five comparable shell seams (`docker/docker-entrypoint.sh:64-67,367`). New "Docker-entrypoint shell seams" table: `PHLIX_JWT_SECRET_FILE` (persisted signing-key path — the security-relevant one; relocate for read-only config volumes), `PHLIX_APP_ROOT`/`PHLIX_SUPERVISORD`/`PHLIX_SUPERVISORD_CONF` (the seams CI uses to smoke-test the entrypoint end to end without an image build) and `PHLIX_SUPERVISOR_FATAL_MARKER` (the exit-70 supervisor-death marker, `rm -f`'d every boot so in-place restarts aren't poisoned). - Docs-only: zero PHP delta — the census/adopters/S180 pins are inert by design (no measured red possible). README-consuming guards re-ran green: `MigrationRunnerTest`'s MariaDB claim-detector (the addition is table rows, no SQL statements), `SkippedTestNameReportingTest`, `TrustedProxiesReadmeRestartClassTest` (heading + TRUSTED_PROXIES rows untouched). - **README headings stop switching costume mid-document (server minors batch W-min106, part 3 — M-SERVER-94 cured).** - **M-SERVER-94 (cured):** four H3s rode title case (`HTTP Endpoints`, `Running Tests`, `Code Standards`, `Git Workflow`) inside a README whose H3 layer is otherwise sentence-case, and the SyncPlay heading joined its siblings' em-dash family with a bare hyphen (`SyncPlay - Group Watching`). All five now read the way the majority already does — the four as case-only flips (GitHub slugs are case-folded, so `#http-endpoints`/`#running-tests`/`#code-standards`/`#git-workflow` never break), and SyncPlay normalized to `SyncPlay — Group Watching` exactly as the review asked; that last one DOES change its slug, so its single inbound link (text + `#syncplay---group-watching`) flipped in the same commit — it is the only reference anywhere in the repo tree. Disclosed premise correction: the ask said sentence case was "already the majority at H2" — at tip the H2 layer is in fact uniformly title-case; the normalization is scoped to the H3 layer the review enumerated, and no H2 moved. Docs-only — zero PHP delta, census/S180/adopters inert by design; no test or CI file pinned any of the old heading strings (measured before and after). - **Profile creation's cap and first-active invariants are now transaction-serialized (server minors batch W-min106, part 2 — M-SERVER-83 cured).** - **M-SERVER-83 (cured):** `UserProfileManager::create()` decided and wrote lock-free — COUNT probe, app-side cap test, first-profile derivation, then two INSERTs. Two concurrent creates for one user could both read count=N and both write (N+1 / N+2 profiles past the cap), both derive `is_first = true` and both land `is_active` (defeating S81's exactly-one-active law), and a failure between the two INSERTs left a settings-less profile row. The body now runs BEGIN → `SELECT id FROM users WHERE id = ? FOR UPDATE` (one X-lock on the parent user row serializes every per-user invariant at once — the same protocol shape as the in-class `switchProfile()` S81 fix and the B2 auth-lock guard) → COUNT re-read UNDER the lock → INSERT profile + settings in the same transaction → COMMIT; any rejection rolls back first. A missing user row is deliberately NOT a new exception: the probe gap-locks the ghost and the FK rejects the INSERT exactly as before — same class, same message, zero wire change. `AuthManager::signupUser`'s first-profile leg switches to the new `createWithinTransaction()` (PDO refuses a nested BEGIN and signup's atomicity is caller-owned — the B2 `guardAndPersistThrough` split). The strlen() name-limit keeps byte semantics (over-long multibyte fails closed against the VARCHAR(100) utf8mb4 column — disclosed nit); non-string names now fold into the same 1-100 refusal instead of the previous deep strict_types TypeError (internal seam hardening; HTTP callers pre-validate). Evidence: unit protocol pins (BEGIN→LOCK→COUNT→INSERT→INSERT→COMMIT order, rollback arm) measured 1-error + 2-failures red against the pre-cure file via single-file stash, green 54/120 restored; real-MySQL `UserProfileCreateLockRace83RealDbTest` 4/46 (host :33062, scratch `phlix_srv83_*`, S345): a held transaction makes the second create() block (1-second `innodb_lock_wait_timeout` 1205 proof, no sleeps-as-sync) and re-validate against the committed view — cap refusal at exactly 5 rows, one single active profile across a serialized first-claim race, a SIGNAL-trigger failure on the settings leg rolls the profile row back to zero, and an unknown user still dies at the FK. Census 2027→2028 and the IntegrationDbGuard adopters 77→78 moved from their measured reds with staircase attribution (writes 1103 held — no new Request-property sites); S180 unrotated (the cap throw is a pre-existing statement moved, not a new site). - **Three requirements/recipe statements now match the shipped law (server minors batch W-min106, part 1 — M-SERVER-11, M-SERVER-26, M-SERVER-59 cured).** - **M-SERVER-11 (cured):** the missing-bundle recipe now says `npm ci` — in the 503 page's folded administrator fix-up (`ErrorPages::uiNotBuilt()`) AND in the boot-time `ViteAssets` message — matching the byte-repro build law the `web-ui.yml` job itself demonstrates (`npm ci`, clean-tree assertion). `ErrorPagesTest`'s visitor-before-recipe marker flipped to `'npm ci'`; the flip is discriminating, measured: the flipped test against the un-cured page copy fails exactly at the recipe-position pin (:63), green after — no second marker needed because the site-guard already bans the old fragment on the controller surface. - **M-SERVER-26 (cured):** the one-line install story gained the Xdebug truth the installer actually ships: the package installs, the `zz-xdebug.ini` it writes is **disabled by default** with a random per-install debug trigger, nothing runs a debug/trace mode unless the operator turns it on, and the production removal law is routed to `systemd/README.md` (shipped law at 1a6ea35c, previous batch). Disclosed: the review's `xdebug.mode=develop` premise was stale at tip — the recipe has been mode-off-default since M-SERVER-73's cure; the README silence was the live half, and it is closed. - **M-SERVER-59 (cured):** Requirements gained its two missing bullets: the 24-entry `ext-*` contract lives in ONE place (`scripts/required-php-extensions.php`, with `composer.json` require + `config.platform` held identical by `RequiredPhpExtensionsContractTest` — count re-derived from composer.json at tip: exactly 24), and Swoole is named with its real posture (the server boots without it — `start.php` skips the curated coroutine hooks — but the `DB_POOL_ENABLED` coroutine pool then has no coroutines to lease connections to, so in-worker queries run sequentially per `config/database.php`). - Self-catch disclosure (fill-time): a drafted README aside about strict mode/`sql_mode` citing `MySqlMode.php`/`MySqlVersion.php` was caught by pre-commit `ls` verification to reference files that DO NOT EXIST and was removed before staging — the committed README carries no such text. Docs + copy only: zero PHP surface files added (three changed PHP files are content-identical in census terms, no new Request writes), so census/S180/adopters pins are inert by design (2027/1103 held, 6/7 green re-verified at tip). Full `tests/Unit/Server/WebPortal/` 17 files + `tests/Unit/Deploy/` 6 files + `SkippedTestNameReportingTest` (the README consumer) + the executable census all re-ran green; phpstan prod-L9 + tests-L2 CI-faithful [OK]; phpcs PSR12 src + tests standards exit 0. - **Static assets on a bare (proxy-less) docker deploy now travel gzip-compressed (server minors batch W-min105, part 5 — M-SERVER-51 cured).** - **M-SERVER-51 (cured):** `serveStatic()` streamed `public/assets/app/**` untouched — the response compressor covered only buffered router replies — while `docker/Dockerfile` + `docker/supervisord.conf` run the Workerman workers on bare **:8096** with no edge proxy, so a direct docker deploy shipped the ~2.2 MB SPA bundle as plain text/javascript (the nginx/Caddy templates help only when adopted). The static branch now negotiates like the buffered arm does: GET/HEAD + `Accept-Encoding: gzip` + a compressible type + size inside `[GZIP_MIN_BYTES, new GZIP_MAX_STATIC_BYTES = 4 MiB]` → one in-memory `gzencode` (same level-6 call as `compressResponse`; the ceiling is the resident-worker honesty budget, and the ask's corpus fits it ~3.2x-served), concrete `Content-Length`, `Vary` merged, the immutable-cache header kept (the variant gate runs AFTER it — placement is load-bearing), and the S113 HEAD law holds per-negotiating-client: the compressed HEAD carries the compressed length, byte-equal to its GET twin. Anything outside the gate — no header, tiny file, image, over-ceiling, non-text — takes the untouched streamed/withFile path, and an incompressible-in-practice result declines too (`gz >= raw` never ships). `HttpHandlerStaticGzipTest` 7/44: the two negotiation pins measured red exactly on the pre-cure file, five decline-path controls green on both codes (incl. the 4 MiB proof that the stream path survives via the vendor `file` property). Brotli stays absent by design (ask acceptable). Zero Request-property writes (method + one header + the file), census 2026→2027 from its measured red; S180 unrotated — every guard returns, nothing throws. - **`PHLIX_SUBDOMAIN_AUTO_CLAIM=0` now actually turns the claim off (server minors batch W-min105, part 4 — M-SERVER-72 cured).** - **M-SERVER-72 (cured):** `config/hub.php` shipped `(bool)(getenv('PHLIX_SUBDOMAIN_AUTO_CLAIM') ?: true)` — the string `0` is PHP-falsy, so `?:` mapped the deliberate opt-out right back onto the on-default: EVERY explicit value, including `0`, left the flag on. The ask called it "dead config" and offered deletion as one option; deletion was rejected — the key has a real consumer (`scripts/claim-subdomain.php` refuses to claim when it reads false), so the honest cure is making the documented opt-out actually work. The line now parses like the file's own `PHLIX_TLS_ENABLED` neighbour a few lines above and the `DB_POOL_ENABLED` law in `config/database.php` (whose comment names this exact trap): unset keeps the historical on-default, `0`/`false`/`off`/`no`/empty switch it off, unrecognised values fail toward off (same tolerant rule). New `HubSubdomainAutoClaimTest` (12/24) pins every spelling, the banana case, and the claim-script wiring itself; measured red 6 failures pre-cure (the five off-spellings + banana all returned true), green after. The README row now names the enforcement point instead of the fiction `1 (on)`. Zero new wire codes, no src/ file moved; census +1 test file re-pinned from its measured red (2025→2026, writes hold 1103), S180 unrotated (no new throw sites). - **The CHANGELOG gains an Upgrade-notes convention and the upgrade story routes operators to it (server minors batch W-min105, part 3 — M-SERVER-46 cured, partially by design).** - **M-SERVER-46 (cured):** the house-discipline preamble gained a bullet: every dated release section opens with `### Upgrade notes` — breaking changes plus the operator actions a deploy must take (rescan-bearing migrations, newly required env vars, changed default semantics), or `None.` when drop-in — written at release-cut time. The ask's second half, "move the S152/S156/S331 callouts under it," is deliberately NOT done: those live inside dated sections, and the preamble's own verbatim-history law (the bullet directly above this one) forbids rewriting history — moving them would rewrite shipped releases. The convention is forward-only; the pre-convention warnings stay where operators already find them — inside their entries (S331's bold `MIGRATION ANSWER` line is the strongest greppable example). README's "Updating an existing install" now routes to the CHANGELOG with the read-every-release-crossed instruction, closing the "deploy-critical notes hide inside the env-var census prose" half from the operator's side. Docs-only: zero PHP delta, census/S180/adopters inert by design; `ParallelTestWiringTest`'s prose-ban tokens kept out of both files. - **"Running Tests" now describes the CI that actually ships (server minors batch W-min105, part 2 — M-SERVER-58 cured).** - **M-SERVER-58 (cured):** the README's test section listed only the `Unit` and `Integration` suites and implied CI ran one serial `phpunit` against a provisioned MySQL. Both halves were stale: `phpunit.xml` carries a third `E2E` suite (`tests/E2E/` — browser + hardware-encoder playback rails, serial-only by measured law), and the `phpunit.yml` job drives Unit/Integration through paraunit with per-worker isolation (`scripts/parallel/run-suite.sh` at parallel 8; Integration against eight `scripts/parallel-test-db.sh` clone schemas — two workers on one schema corrupt each other's counts) with E2E as a serial tail. The section now states the real shape, names the E2E suite and its serial law, and documents both scripts as the supported local seam (previously zero README mentions — the seam was undiscoverable outside the workflow file itself). `SkippedTestNameReportingTest`'s README-consumer pin (the section documents the skip-name-set contract) re-ran green over the edited file: 47/362. Docs-only — zero PHP delta, census/S180/adopters inert by design; no `ParallelTestWiringTest` sentinel tokens entered prose. - **Three operator-facing README statements now match what actually ships (server minors batch W-min105, part 1 — M-SERVER-41, M-SERVER-42, M-SERVER-55 cured).** - **M-SERVER-41 (cured):** the docker section's migration-failure bullet said the container "by default still starts" — true of the raw script, but the shipped images set `PHLIX_MIGRATIONS_STRICT=1` (`docker/Dockerfile`, `Dockerfile.intel`), so a compose deploy from the published images refuses to boot on a failed migration. The bullet now names that inversion and points at `docker/README.md` (the env-table row was already honest). - **M-SERVER-42 (cured):** "What requires a restart" never routed to the mechanism write-up. It now links `docs/dev/settings-restart-gap.md` (what a graceful reload can and cannot re-read) and names the UI-reachable trigger — `POST /api/v1/admin/restart` (admin-only) JSON-acks before the SIGUSR2 cycle — neither of which appeared anywhere in the README before. - **M-SERVER-55 (cured):** the env census named 6 of the 11 real rate-limit surfaces; `<SURFACE>` now enumerates the full `RateLimitProfiles` set (adding `PIN_VERIFY`, `QUICK_CONNECT_INITIATE/_STATUS/_APPROVE`, `TELEMETRY_HEARTBEAT`) with a clause naming the profile map as the denominator. Disclosed: the ask's other half — the `PHLIX_THEME_MUSIC_*` trio — was already in the census (stale at dispatch; rows untouched). Docs-only; the whole Deploy README-pin family is green at tip (TrustedProxiesReadmeRestartClass 9/29 covers the extended restart section verbatim), zero PHP delta so census/S180/adopters are inert. - **The host installer stops shipping the publicly-known Xdebug trigger default (server minors batch W-min104, part 2 — M-SERVER-73 cured).** - **M-SERVER-73 (cured):** `scripts/install.sh` wrote `xdebug.trigger_value = "${XDEBUG_TRIGGER:-devdebug}"` into `/etc/php/*/fpm/conf.d/zz-xdebug.ini` on every host deploy. With `start_with_request = trigger`, anyone who knew the published default — and could reach PHP at all — could start request traces into the shared `/tmp/xdebug` (disk-filling DoS + request-flow disclosure) once mode was ever enabled. The installer now resolves the trigger like the honest docker examples do (`docker/examples/.env.example`): an exported `XDEBUG_TRIGGER` wins, otherwise a fresh `openssl rand -hex 12` (with an `/dev/urandom` fallback), and a total entropy failure aborts loudly instead of writing an ini whose empty value would DISABLE the gate. The concrete value is never echoed (installer output can be logged; the ini is root-owned and the operator reads it from there), the post-install output now carries the same rotate-and-remove guidance the docker example gives, and `systemd/README.md` gained the production posture the review asked for: remove `zz-xdebug.ini` on production hosts or keep `XDEBUG_MODE` off and never export it. Disclosed: `docker/php.ini` still carries the `${XDEBUG_TRIGGER:-devdebug}` line — the ask scoped the fix to the host installer path (the container default is inert under the shipped `XDEBUG_MODE=off` and the docker docs already warn); left for its own item. No PHP surface changed — census, S180 and adopters pins untouched; `bash -n` parse pin of `InstallerFirstRunProseTest` stays green. - **Opening a playlist or collection no longer re-sorts its whole item list on every read (server minors batch W-min104, part 1 — M-SERVER-23 cured).** - **M-SERVER-23 (cured):** `CollectionItemRepository::findMediaItemIdsForCollection()` — the rail behind every playlist/collection id list — runs `WHERE collection_id = ? ORDER BY sort_order, added_at`, while migration 005 gave `collection_items` only `PRIMARY (collection_id, media_item_id)` and `idx_ci_media (media_item_id)`: nothing led `(collection_id, sort_order)`, so each read located the rows via the PK and then **filesorted the entire collection**. Migration 123 adds `idx_ci_collection_sort (collection_id, sort_order, added_at)` through the guarded INFORMATION_SCHEMA + PREPARE idiom of 107/121 (replay short-circuits to `SELECT 1`, never eats 1061). The composite also makes the rail *covering* — InnoDB appends the PK columns to every secondary index, so `media_item_id` is read from the index itself. The PK and `idx_ci_media` stay: they carry the uniqueness contract and the media-side leg of migration 122's FK (same keep-both reasoning as 114/115). Real-MySQL proof `CollectionItemsSortIndexMigration123RealDbTest` (3/30): pre-apply `EXPLAIN` pins the defect (key=PRIMARY + `Using filesort`), post-apply pins `key=idx_ci_collection_sort`, `Using index` and no filesort, order-equivalence against the forced-filesort path over 300 tie-carrying rows, ledger + ledger-stripped replay no-ops; a mutation neutering the ALTER to `SELECT 1` reddens exactly two pins. The census and the IntegrationDbGuard adopters moved 2024→2025 / 76→77 from their measured reds (one test file added; the cure is a .sql — inert to the PHP census, zero Request-property writes, zero new throw sites so the S180 baseline did not rotate). Disclosed: the queue files this item under batch W-min106 while this vein's orchestrator brief dispatched it inside the W-min104 cluster — ask text is the authority, labels noted in the fixlog; no `@phlix-require-mysql` behavior beyond the guard's own INFORMATION_SCHEMA read (8.0.13 floor already stated in README per M-SERVER-13's cure). - **The newsletter template's own header no longer advertises the unsubscribe link the cure removed (M-SERVER-02 review forward-fix, r1).** - **M-SERVER-02 r1 (forward-fix):** the cure at `3e71153a` rewired the mailed anchors and replaced the fake Unsubscribe row with the true control-plane sentence, but left two dead residues in `public/templates/emails/newsletter.tpl` that the adversarial review named: the file's silent-comment header still listed "- Unsubscribe link" (a feature claim the template can no longer render — exactly the dishonesty class the original review flagged), and an orphan `.unsubscribe-link` CSS rule styled an element no shipped code emits (verified: zero references repo-wide besides the rule itself). Both are gone: the header line now names the control statement truth, and the dead rule is deleted. No behavior change; the cure machinery, the `{if $links_base}` render arms and `NewsletterLinksTruthTest`'s 3/8 pins stand untouched. Disclosed: the guard's committed-template scan pins the closing-tag form `Unsubscribe</a>` and therefore cannot see the silent-comment header — the header line is prose-truth, not markup, and its verification lives in review, not in the regex. - **The README stops advertising a sync-tolerance knob nobody can turn (server majors true-miss lane, part 5 — M-SERVER-31 cured).** - **M-SERVER-31 (cured):** README promised "Position Tolerance: Configurable sync tolerance (default 2s)" — but no config key, admin setting or env for it exists anywhere (config-plane grep: zero tolerance keys), and the constructor seams on `GroupState`/`SyncPlayManager` are never passed a non-default by any shipped caller. The line now tells the fixed-window truth: 2000 ms, `GroupState::POSITION_TOLERANCE`, explicitly no config surface, seams disclosed as test-only. New guard `PositionToleranceReadmeTruthTest` is two-directional: it fails the fiction's return, re-derives the millisecond value from the enforcing constant, and re-checks the config plane each run — so the day a REAL tolerance key ships, the "no config key" disclosure reddens and the docs must be updated to describe the knob. Sibling denominators unchanged; the estate census was re-pinned from its measured red in this commit (files 2019→2024 = this lane's five guard files; declared Request-member writes 1101→1103 = the envelope test's two harness seeds), attributed in its own docblock staircase. - **The profile-not-found denial finally says profile-not-found (server majors true-miss lane, part 4 — M-SERVER-04 cured).** - **M-SERVER-04 (cured):** an authenticated request whose profile row was missing — or present but carrying an unresolvable id — was refused with `error: "StreamLimitExceeded"` at three sites (`StreamLimitMiddleware` both fail-closed arms and the `PreRouterFastPaths::profileNotFoundResponse()` fast path), while the real cause was an absent profile and no stream limit had been checked. Clients reading the label took users to a concurrency explanation that never applied. All three now emit the honest pair the code already carried underneath: `code profile.not_found` + `error "Profile not found"` + the actionable member message ("No active profile for this session. Pick or create a profile, then retry."), status 403 and `denial_type` unchanged — no wire codes added, and the legitimate 429 `stream.limit_exceeded`/`StreamLimitExceeded` pair (the actual limit rejection) was deliberately left intact per the review. New guard `StreamLimitProfileNotFoundEnvelopeTest` pins the middleware envelope end-to-end over the real service — both arms, including the unresolvable-id row shape the second gate exists for — and the pre-existing fast-path pin at `PreRouterFastPathsStreamLimitTest` was re-anchored from the lie to the truth (measured red, then green; mutation-armed: renaming the label back reddens three pins). - **Forced password change names the only actor who can actually unblock the member (server majors true-miss lane, part 3 — M-SERVER-03 cured).** - **M-SERVER-03 (cured):** `PasswordChangeRequiredException` told locked members to "please use the password reset link" — a link this server never sends: the forced-change 403 fires before authentication, and the reset machinery on the wire is admin-side only (no self-service reset-token completion flow exists; the AuthManager comment already said so). The message now matches reality: the change is required before signing in and the server administrator is the one who resets it. New guard `PasswordChangeRequiredCopyTest` pins the member-facing sentence and keeps the phantom reset-link promise from returning; no wire code, status or envelope shape moved. - **Weekly newsletter links resolve against the deployment's public URL — or the email ships without links at all (server majors true-miss lane, part 2 — M-SERVER-02 cured).** - **M-SERVER-02 (cured):** every anchor in `public/templates/emails/newsletter.tpl` shipped as a literal `href="#"` — "View in Phlix", "Visit Phlix", "Help Center", "Preferences" and the worst offender, an "Unsubscribe" link promising an opt-out that leads nowhere (no user-facing unsubscribe route exists on this server) — and neither `NewsletterGenerator` nor `NewsletterSender` performed any href substitution, so the `#` went out verbatim in every mailed report. Links now render only against a real base URL: `newsletter.base_url` (new honest config key) falling back to `hub.public_url` — the same public hostname pairing/Quick-Connect advertise — resolved at the wiring site; `NewsletterGenerator::resolveLinksBase()` fails any non-absolute value closed to "no links". The CTA and "Visit Phlix" point at the shipped SPA root `{base}/app`, "Preferences" at the shipped `{base}/app/settings`; "Help Center" is gone (no such page exists) and the fake unsubscribe row is replaced by the truth: the newsletter is controlled by the server administrator's toggle. Without a base URL the template emits zero anchors — never dead ones. New guard `NewsletterLinksTruthTest` pins the committed template free of `href="#"`/Help Center/Unsubscribe text, both render arms (base present: exact resolved hrefs; base absent: no `<a href=` anywhere), trailing-slash normalization, and the honest footer sentence. - **The installer's closing summary sends new self-hosters to the first-run flow that actually exists (server majors true-miss lane, part 1 — M-SERVER-01 cured).** - **M-SERVER-01 (cured):** `scripts/install.sh` ended every fresh install with "Open the URL and complete the first-run setup wizard." — a wizard that exists nowhere in the product (the word appeared exactly once repo-wide: on this promise line). The shipped first-run is the CLI pair the README's "Bad days" section already documents: create the account with `bin/phlix user:create`, promote it with `user:promote`, sign in via the SPA. The Next block now names both commands inline (the hub-pairing line stays). New guard `InstallerFirstRunProseTest` forbids the promise — the only lawful mention of a wizard in the installer is the disclosure that none exists — pins the two real commands in the block, and keeps `bash -n` on the script. - **The server's fallback pages become actual pages (server minors batch W-min103, part 3 — M-SERVER-87 cured).** - **M-SERVER-87 (cured):** the last-resort 500 was `'<h1>500 Internal Server Error</h1>'`, five `dispatch()`-family sites shipped `'<h1>404 - Page not found</h1>'`, and the missing-bundle 503 told an end *user* to `Run cd web-ui && npm install && npm run build` — bare fragments with developer-only copy, browser-default CSS, no lang/viewport, no home affordance. New `src/Server/Http/ErrorPages.php` is the single source: each fallback is now a complete document in the shape the repo already carried as its fix-pattern (`TraktOAuthController::notConfiguredPage()`) — doctype, `lang`, charset, viewport, `robots noindex`, a styled layout that follows the visitor's light/dark preference, and a link back to the web portal. The npm recipe did not vanish; it sits behind visitor-first copy inside a collapsed admin `<details>`. The 500 copy stays generic — the pre-existing no-exception-leak law is kept (detail belongs to the HTTP error log, which already carries class, message, file and line). S113 is untouched: the raw 500 arm still derives its `Content-Length` from the body bytes (`serverErrorHtml()` exists so the arm can see them) and still answers `HEAD` bodyless; the five app-`Response` arms ride the existing head-only flag. `HttpHandlerHeadNoBodyTest` byte-laws re-anchored to document markers + GET-measured length (red-first on the pre-cure tree, green after); new `ErrorPagesTest` pins the chrome element-by-element, the status codes, the visitor-before-recipe ordering, and a site guard that the bare fragments never creep back (call-site counts pinned 5+1+2+1). Disclosed as the ask's one unshipped element: a request id on the 500 page — minting one post-crash means a new header/correlation story across the estate; recorded here rather than dropped silently. No wire codes added; census 2017→2019 from the measured red. - **Both upload endpoints now speak one wording for one failure (server minors batch W-min103, part 2 — M-SERVER-08 cured).** - **M-SERVER-08 (cured):** `ProfilesController` carried a private upload-error table whose docblock claimed it "mirrors UserAvatarController" while three arms had drifted — INI and FORM size merged into "File exceeds size limit", "Missing temporary folder" reworded to "Server is missing a temporary folder", and the extension arm to "A PHP extension stopped the upload". One source of truth now: `src/Common/Http/UploadErrorMessages::describe()` carries the canonical (UserAvatar) arms — the precise INI/FORM split and the wording already pinned end-to-end by `UserAvatarControllerTest` — and both controllers delegate to it; the private tables and the lying docblock are gone. New arm-table law test (`UploadErrorMessagesTest`, incl. an `UPLOAD_ERR_OK` defensive pin so a future caller mishandling the gate cannot render "OK" as "No file was uploaded") plus a Profiles-side form-size behavior pin that was silently merged before (measured red against the old table, green after). Wire copy only: no error codes added, the registry and openapi enum are untouched. - **The collections tables finally carry the foreign keys their comments always advertised (server minors batch W-min102, part 3 — M-SERVER-22 cured).** - **M-SERVER-22 (cured):** migration 005 created `collections` and `collection_items` with column COMMENTs claiming "FK to collections.id" / "FK to media_items.id" and a library-owned `collections` — but declared NO foreign key on either table, so deleting a library, collection, or media item left membership rows dangling forever (read paths hid them via JOIN misses; COUNTs and triage met them). New migration 122 prunes every dangling edge first (parent-most-first: collections-by-library, then item rows by either endpoint — so a single pass converges, including the rows of collections the library prune itself removes), then adds exactly the three edges the comments promised, each EXPLICITLY named and guarded by an `INFORMATION_SCHEMA.TABLE_CONSTRAINTS` count (100's law: an unguarded duplicate constraint name raises 1826, which is not a runner-idempotent code): `fk_collections_library`, `fk_collection_items_collection`, `fk_collection_items_media_item`, all `ON DELETE CASCADE`. Scope held to the review's ask: the `parent_id`/`smart_playlist_id` links stay constraint-free by decision (smart playlists have their own lifecycle; widening here would be un-asked behavior). No new indexes — the existing PK/`idx_ci_media`/`idx_col_library` already answer the keys. Real-MySQL proofs (`CollectionsForeignKeysMigration122RealDbTest`, live server 8.4): prune converges so the constraint build survives (mutation-proven red when a prune leg is removed — a surviving orphan dies the ADD 1452), each edge exists named+CASCADE, all three actually cascade on live deletes, a new orphan INSERT is rejected where the old schema accepted it, and replay is a no-op both ledger-skipped and with the ledger row stripped. The M-SERVER-23 collection-index ask is a DIFFERENT queue item and was deliberately not folded in here. - **A media item is created atomically, and a failed create can no longer read back as success (server minors batch W-min102, part 2 — M-SERVER-21 cured).** - **M-SERVER-21 (cured):** `ItemRepository::create()` wrote its two legs (`media_items` INSERT, then the `media_item_genres` join rows) under plain autocommit, while `upsertByPath()` caught ANY `Throwable` from it and asked the database "does a row for this path exist now?" — so a create that died on the SECOND leg left the first leg's orphan half-row behind and the catch found it, returning a silent success with a genre set that never landed (the docblock's promised "any other failure is re-thrown unchanged" only held when the lookup happened to miss). The two write legs now run inside one transaction following the in-class `replaceStreams()` template (no-nest law documented at both: no caller opens an outer transaction today — `batchCreate()` verified). The stats record and the genre-facet invalidation deliberately run AFTER the commit: the stats call swallows every failure of its own and the vendor `execute()` rolls the open transaction back before re-raising, so inside the txn one stats hiccup would have silently unwound the item row while create() still returned an id. The upsert catch is narrowed to the genuine duplicate-key error in both vendor shapes (raw `PDOException` with `errorInfo[1]` = 1062, and the re-wrapped "SQL:… Duplicate entry…" form whose driver code the re-wrap drops); an FK rejection or any other failure now surfaces even when a row for the path exists, and a 1062 on a DIFFERENT key with no row for the path surfaces too. Real-MySQL proofs (`MediaItemCreateAtomicityRealDbTest`, live server 8.4): genre-leg ERROR 1146 rolls the item row back and propagates — measured red on the pre-fix code (it returned a laundered success); a real unique-index collision on `(library_id, path_hash)` is still recognised as the lost race and reuses the winner's row; a non-duplicate failure with an index-exempt seeded row at the same path surfaces instead of being found-and-returned. Unit pins cover the BEGIN→INSERT→INSERT→COMMIT call order, the rollback arm, both vendor error shapes and the rethrow arms (one pre-existing pin encoded the loose law with a `RuntimeException`-shaped duplicate and was converted to the vendor's actual `PDOException` shape). - **Deployment docs tell the truth about the sandbox and the browser surfaces (server minors batch W-min102, part 1 — M-SERVER-29 and M-SERVER-96 cured).** - **M-SERVER-29 (cured):** the `systemd/README.md` security-hardening bullet listed only five of the unit's nine `ReadWritePaths` — an operator diffing README against `phlix-server.service` saw four undocumented writable paths (`var/` for runtime plugin installs and theme extraction, `/var/artwork`, `/var/subtitles`, `/var/cache/phlix/anidb`). The bullet now enumerates all nine with a one-clause purpose each, mirrors the unit's own comment stories (the `/var/artwork` read-only-deadlock lesson and its two repeats), and keeps the operator-placed-log note for `/var/log/phlix`. The unit file itself is untouched — the prose moved to match it, not the other way around. - **M-SERVER-96 (cured):** the README drifted across three names for one surface — "admin console", "Admin UI", "admin-SPA card", "admin area" — and the Overview bullet led with "Vue SPA … the ONLY web UI" instead of the surface name. `web portal (/app)` and `admin section (/app/admin)` are now the canonical pair: defined once in the Overview, then used consistently (heading renamed, historical "React + Vite admin console" narrative kept verbatim as history, `#web-portal` anchor cross-reference held). - **Profile refusal envelopes answer the estate `{error, code}` shape — machine tokens no longer park in the human slot (server minors batch W-min103, part 1 — M-SERVER-07 cured).** - **M-SERVER-07 (cured):** "Unlike other controllers this repo pairs no `code:` key with them, so clients that render `error` … show raw identifiers like `profile.pin_mismatch` on a TV." Four `ProfilesController` refusals (`profile.use_switch` 400, `profile.last_profile` 409, `profile.no_pin` 409, `profile.pin_mismatch` 403) carried the dotted code AS the `error` text — the exact split-brain shape phlix-ui's api client names and defensively translates. All four now emit through the house `Response::error(status, code, human-text)` helper: `error` carries the human sentence, `code` carries the (already registry-registered) dotted key, so clients localize by `code` per the error-code doctrine instead of string-matching the human slot. The four codes were added to the OpenAPI `Error.code` enum from the measured red of the emit-scan contract test (the enum is DERIVED from what `src/` can emit, so the spec grew exactly the four members the scan proved on-wire); the `@phlix/contracts` registry itself is byte-untouched — these codes shipped with it long ago and are not new. Wire disclosure: the 400/409 pair also previously carried a second-slot `message`; the bespoke slot retires (its operational text lives in the OpenAPI description and the ui catalog now that `code` resolves). Status codes unchanged. `ProfilesControllerTest` pins flipped red-then-green (code-slot identity + exact human text ×4); `ErrorCodesContractTest`/`ErrorCodesOpenApiEnumContractTest`/`OpenapiSpecCurrencyTest` green on the new enum (55 members); phpcs PSR-12 src + tests standards clean. - **Embedded subtitle extraction is now persisted — the same once-per-track contract downloaded subtitles already had (server minors batch W-min101, part 6 — M-SERVER-54(a) cured).** - **M-SERVER-54 (cured, other half: the `.mov` MIME in part 5):** `GET /api/v1/media/{id}/subtitles/{index}` ran a cold `tempnam` + ffmpeg conversion of the embedded track on EVERY request, so a player that re-fetches `<track>` srcs (seek, reload, Safari's per-track-mode toggling) paid the full transcode repeatedly. New `EmbeddedSubtitleCache` persists the cleaned WebVTT under the operator's own `subtitles.storage_path` volume in its own `embedded/` namespace (remote `<lang>.vtt` files and `embedded-*.vtt` derivations never share a directory), per-item directories and the path-traversal jail mirroring `SubtitleStorage`. The source fingerprint (mtime + size, stat-cache cleared per resident-worker request) is part of the FILENAME — replacing the media file changes the key, the next request re-extracts, stale generations cannot alias; failed extractions stay uncached so a transient ffmpeg error never ossifies into a permanent 404; cache writes are best-effort and atomic (a full disk serves the request anyway, the house posture for derivations). Pinned by `SubtitleControllerTest` (extract-exactly-once across two requests — measured red against the uncached controller; replaced-source re-extraction; failure-retry), `EmbeddedSubtitleCacheTest` (layout, sanitisation, DIRECTORY-granular jail incl. the shared-prefix sibling, write-failure-is-false), `EmbeddedSubtitleCacheWiringTest` (the cache roots at the configured volume + `/embedded` and is one instance per container); census denominator re-pinned 2010→2013 files from the measured red, writes held at 1100. - **The direct-play fast path stops labeling `.mov` files as `video/mp4` (server minors batch W-min101, part 5 — M-SERVER-54(b) cured).** A QuickTime `.mov` carries its own registered container type; two of the estate's three extension→MIME tables (`DlnaMimeTypes`, `ThemeMediaStreamController`) already answered `video/quicktime`, and only the pre-router stream mapper still told every browser the reply was MP4. The row now matches the registered type; the `streamMimeFor` data table gained a discriminating `mov-quicktime` case (measured red against the old row before the fix landed). - **Every reply the HTTP worker sends now carries the security-header and CORS decoration — including the three early exits that used to skip it (server minors batch W-min101, part 4).** - **M-SERVER-50 (cured):** the static-file arm, the CSRF-rejection 403 and the pre-router fast-path arm (avatars, artwork, direct-play streams) of `HttpHandler::__invoke()` each shipped raw — no `X-Content-Type-Options`, no CSP/HSTS/X-Frame-Options, and no CORS echo for an allowlisted native origin — while the `CorsManager` class docblock asserted every final response passed through it. The fast-path arm now runs the exact same `decorate()` calls the dispatched branches use; the two raw-Workerman arms apply the identical value sets through new `SecurityHeaders::headerValues()` / `CorsManager::headerValues()` generators (single source: `decorate()` itself now consumes them, so the two paths cannot drift), with caller-wins guards against headers the arm already set and the empty-allowlist no-op preserved byte-for-byte. File-backed (`withFile()`) and S113 head-only (`BodylessResponse`) replies keep their exact wire shapes; the avatar-comment's premise about fast-path replies lacking `nosniff` is retensed to history, which is exactly when it became load-bearing. The stale "CGI public/index.php" entry-point claim in the `CorsManager` header was corrected to the post-S171 single-entry truth along the way. Pinned by `HttpHandlerFastPathDecorationTest` (9 tests: the three arms decorated, allowlisted/unlisted/foreign origins, head-only survives, dispatched control unchanged); census denominator re-pinned 2009→2010 files from the measured red. - **The SyncPlay WebSocket auth middleware's opening line now states the carrier law its own flow section already documents (server minors batch W-min101, part 3).** - **M-SERVER-98 (cured):** the class docblock summary of `SyncPlayAuthMiddleware` claimed it validates "session tokens from the WebSocket handshake `?token=...` query parameter" — stale since the bearer-subprotocol flip two sections below it had already captured correctly. The summary now reads: validates the handshake JWT (bearer subprotocol preferred, legacy `?token=` accepted — see `docs/dev/WEBSOCKET_AUTH_CARRIERS.md`). Comment-only; behavior untouched, the document it cites is the SSOT rail (`resolveHandshakeToken()`). - **The Troubleshooting chapter gains its diagnostic-endpoint survey, the plugin bullet says out loud that installing is not enabling, and the relay TLS knob states what off actually permits (server minors batch W-min101, part 2).** - **M-SERVER-43 (cured):** `README.md` `## Troubleshooting` opens with a new `### Diagnostic endpoints` subsection — the four operator rails a triage session should hit first, each verified against the composed router at this tip: `GET /api/v1/health/relay` + `GET /api/v1/health/network` (`Application.php:349/:355`, `Admin/HealthController::relayHealth/networkHealth`, member `AuthMiddleware`, staleness-gated reads and the healthy/degraded/offline latency law documented), `GET /api/v1/admin/logs` with `/logs/tail` + `/logs/tail-all` (`AdminRoutes.php:259-261`, basename-only `file` param), and `POST /api/v1/admin/maintenance/reap-transcode-jobs` (`AdminRoutes.php:410`, body `{older_than_seconds: int}`) — plus the note that bare `/health` stays the open infra-probe rail. None of these was named anywhere in the README before. - **M-SERVER-44 (cured):** the Features plugin bullet now states the install/enable split that the admin UI's behavior implies: installing only stages (files + sandboxed autoload), enable activates registration (routes, event subscriptions, settings), and disabling is the first troubleshooting step for a misbehaving plugin. - **M-SERVER-71 (cured):** the `PHLIX_RELAY_REQUIRE_TLS` env-table row no longer ends at the file name — it now says the truth `RelayConfig.php:156-158` implements: on refuses non-`wss://` dials outright, off (the default) is warn-only (a plaintext tunnel still dials behind a connect-time impersonation warning), so production installs should set `1`. - Docs-only: zero PHP touched, census unchanged, no string-pin tests reference the edited prose (the README-reading tests pin `TRUSTED_PROXIES`/backup/lockout/reset sections only). - **The required-parameter envelope copy converges on one generation, the terminal-period outliers lose their periods, and four 121-char lines from this batch's own part 1 get reflowed (server minors batch W-min101, part 1).** - **M-SERVER-02 (cured):** nine `Missing ...` 400-envelope sites became the `X is required` family the MarkerController already spoke: ExtrasController ×3 `Missing media item ID` and RemoteSubtitleController ×2 `Missing media id` → `Media item ID is required` (three generations for the SAME parameter — the review's clincher — now one); SessionController `Missing required field: device_id` → `Device ID is required`, `Missing required fields: media_item_id, position_ticks` → `Media item ID and position ticks are required`, `Missing required field: media_item_id` → `Media item ID is required`; LibraryController create-library `Missing required fields: name, type, paths` → `Name, type and paths are required`. Five flipped `assertSame` pins (Extras ×3, Session, Library) red-then-green against the pre-cure controllers; status codes, wire codes and payload shapes untouched. The remaining older csv sites (AuthController register/login, WebAuthn, Arr SyncController, SmartPlaylist, WebhookAdmin create) are the review's "third generation" cohort left for a scheduled follow-up — this item's evidence names the media-id pair and the Session/Library variants, and silently widening to eight more pinned surfaces this late in the batch is the drift this campaign forbids; SyncPlay `Messages.php`'s `Missing required field: type/protocol_version` are WebSocket protocol payloads, not HTTP envelopes, and stay; `GuideManager`'s csv string is a log row, not an envelope. - **M-SERVER-04 (cured):** the trailing-period outliers collide no more. `WebhookDispatcher::dispatchToWebhook()`'s trio (`Webhook delivery is disabled (webhooks.enabled is false)`, `Webhook not found`, `Webhook is inactive` — they surface as admin delivery-result rows beside `WebhookAdminController`'s period-free twins, same page both spellings before) lost theirs; the single-sentence `message` family `Settings updated.` lost its period at all five response sites (AdminSettingsController ×3, AdminUpdatesController, WebPortalRouter) plus its docblock example. `AdminUpdatesRoutesTest`'s contains-pin was made discriminating (`assertStringNotContainsString('Settings updated.')`) — red on the pre-cure controller, green after. The webhook trio's copies had no test pin to flip (the existing 'inactive' coverage is a period-insensitive substring); disclosed. DLNA/autoCollections period outliers belong to other items. Periods remain legal where the law allows: multi-sentence copy only. - **Forward-fix, this lane's own part 1 (M-SERVER-12 era):** the four `You do not have permission to access this session` 403 lines the W-min100 part 1 shipped were 121 chars — the src PHPCS gate has run without `-n` since S109, so warnings fail it, and every push since `f27d0b98` carried a red Coding Standards job unwatched (disclosed). Reflowed to the multi-line `json([...])` idiom; whole-tree `phpcs --standard=PSR12 src/` now reports zero findings. - **Two repo-root operational docs now tell the truth about what they are — the k8s migration claim is corrected to the boot-ledger behavior, and the ui-vendor runbook is retitled and path-aligned (server minors batch W-min100, part 4 — docs).** - **M-SERVER-18 (cured):** `k8s/README.md` claimed migrations "run automatically on first startup" — they run on EVERY container start, ledger-skipped when unchanged (`docker-entrypoint.sh` → `scripts/run-migrations.php` → `schema_migrations`), which is what makes a new release's migrations land on the new image's first boot. The section now states that mechanism, tells operators to take a `php bin/phlix backup:create --label=pre-vX.Y.Z` backup BEFORE upgrading, and points at the `RELEASE_PROCESS.md` "Restore from a backup" runbook for the restore/rollback semantics — the manual `mysqldump --single-transaction --quick --lock-tables=false` recipe the ask asked for already lives in the README's backup section, and the runbook's honest list of what a restore re-imports (DB dump + config only, never `data/` or SSL) was verified on disk; nothing was added twice. - **M-SERVER-99 (cured, docs-currency):** `server_deploy.md` — a `@phlix/ui` bundle re-vendor runbook — opened with the title "phlix-ui Deployment — phlix-server" and drove every command through `/srv/phlix` while the README documents the installer's real default `/var/www/phlix`. Retitled to "Updating the vendored \`@phlix/ui\` bundle — phlix-server" (filename kept — nothing in-tree links it), its nine server-side paths aligned to `/var/www/phlix`, and the template note now says exactly which parts are placeholders (the hub-side `/srv/phlix-hub` line belongs to another repo's layout and was left alone). `performance_worklog_server.md` — an 8,000-line frozen recon record whose tooling figures (the "Unit + Integration + E2E" default, the "(level 2)" tests gate) contradict today's `AGENTS.md` — carries a header stamp naming it history, not instructions. `CALIBER_LEARNINGS.md`'s three near-duplicate bullet pairs (the unbounded-`Read` gotcha, the `composer validate --strict` convention, the `Workerman\MySQL` int-port gotcha) deduped to the superset variant of each; the caliber file's content was preserved, only exact repetition removed. - **Transcode segment params now clear a per-slot allow-list before reaching the ffmpeg command line, closing a trust-by-caller convention (server minors batch W-min100, part 5).** - **M-SERVER-77 (cured):** every `segment_params` string `FfmpegRunner` used to splice **unescaped** into the ffmpeg argv — `-c:v`, `-preset`, `-c:a`, `-b:a`, the browser-safe `-pix_fmt`/`-profile:v`/`-level` flags, and the threaded `tone_map_filter` that lands inside the double-quoted `-vf "…"` context — now passes through a boundary gate (`argvParam()`) and is REJECTED before any command is built when it falls outside its slot's family: codec/preset/pixel-format/profile tokens `[A-Za-z0-9_]{1,64}`, levels numeric (`4.1`/`41`), audio bitrates digits with optional k/M suffix, filter graphs `[A-Za-z0-9_=:%,]{1,4096}` — a set excluding every character that could break out of the shell string or the quote context. The review found no exploitable chain and this lane re-confirmed it (the sole writer is `TranscodeManager`, whose values are constants plus the `EncodeSettings` allow-list; subtitle burn-in keeps its own filtergraph escaping, untouched here); the cure targets precisely the named defect — an argv escape surface held shut by DB-writer discipline. Fail-loud rejection: the slot name is logged, the offending payload never is (it is the injection attempt), and an `InvalidArgumentException` stops the build. Every legitimate producer domain still passes verbatim. Fourteen pins in `FfmpegRunnerArgvBoundaryTest` (ten poison arms across the three builders, four acceptance arms) measured red-then-green: on the pre-cure runner every poison reached the command verbatim. The new throw sites are REACHED per the S180 prober, so its baseline did not rotate; the S431 estate census re-pinned 2008→2009 from measured red. - **The book, audiobook and telemetry-census paging knobs now share ONE coercion policy instead of three hand-inlined ladders — and the hand-rolled `min(100, max(1, …))` idiom is gone from the controllers (server minors batch W-min100, part 4).** - **M-SERVER-84 (cured):** `BookController::opdsLibraryBooks()`/`::listBooks()` and `AudiobookController::listAudiobooks()` routed their `?limit`/`?offset` through `PageLimit::clamp()`/`PageLimit::clampOffset()` — the same 50-default, 1..100 bound, same `(int)` coercion the inline expressions computed, so every endpoint's HTTP bytes are unchanged; eighteen behavior-identity pins (`PageLimitSitesTest`, six per endpoint incl. the OPDS acquisition feed's repo call binding) were green against the OLD inline code first and stay green against the policy — a moved bound could not survive them. `QuickConnectController::adminClients()` gains the new `PageLimit::coerce()` — the parse half of `clamp()`, no bounds — because that surface's 1..1000 ceiling is a deliberate downstream override owned by `ClientHeartbeatStore::recent()` at the SQL bind (already pinned there; two more pins now prove the handler passes an oversized census request through unclamped and defaults junk to 200, exactly as before). `BackupManager::cleanupOldBackups()`'s `LIMIT 1000 OFFSET ?` is documented as what it always was: a worker-side delete budget per tick, not a caller-facing page, backlog collected by the next run — comment only, zero behavior. No wire codes, no response shapes, no bounds moved; the S431 census re-pinned 2007→2008 (files) and 1097→1100 (the sanctioned harness-seed writes) from measured red. - **A legacy ownerless marker row can no longer be erased by just any signed-in member — the NULL-`user_id` delete arm is now admin-only (server minors batch W-min100, part 3).** - **M-SERVER-80 (cured):** `MediaMarkerController::deleteMarker()` treated `user_id IS NULL` (pre-P3-S1 legacy rows) as "deletable by any authenticated user", so every member could wipe another account's or the system's markers on any item. The arm now demands an ACTIVE admin — resolved through `UserRepository::findAdminById()` (the same members-or-admin predicate the visibility lanes use), the controller gaining it as a required constructor collaborator (PHP-DI autowires it; the `Application` non-container fallback constructs it beside the marker service). Non-admins on a NULL-owner row get `403 Admin access is required to delete a marker without an owner`; user-owned rows keep the byte-identical `403 Not authorized to delete this marker`; the owner and admin-curation arms delete exactly as before. No new wire codes, no status changes outside the closed arm. Four law pins in `MediaMarkerOwnershipTest` run the REAL `MarkerService` over the house-standard mocked `Connection` so the bound `DELETE` is the assertion (red-then-green shown: on the pre-cure controller the non-admin arm deletes instead of refusing); the S431 estate file census re-pinned 2006→2007 from measured red. - **Year sorting and year letter-buckets now ride an indexed materialized column instead of re-extracting JSON on every row (server minors batch W-min100, part 2).** - **M-SERVER-20 (cured):** `year_sort` becomes a real `SMALLINT UNSIGNED STORED` column on `media_items` (migration `121_media_items_year_sort.sql`) — the release year the old `CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.year')) AS SIGNED)` recomputed per row on every year sort and every year value-bucket, now derived once at write time and indexed alongside the library (`idx_media_items_library_year`). The migration's CASE guard is load-bearing: a bare CAST of JSON `null` dies 1292 `Truncated incorrect INTEGER value` during the STORED-column rebuild (measured on real MySQL), so years that are absent, JSON null, non-numeric or decimal fold to a single `0` bucket, `> 32767` saturates, and a leading-minus value folds to `0` too (the guard is `^[0-9]+$` — a disclosed deviation from prefix-parsing, chosen so junk can never poison an indexed column). The library year `ORDER BY` and the year bucket/group expressions both read `year_sort` now; three unit pins that had frozen the JSON-extraction text flipped to the column law and two new SQL-shape pins freeze the cure; a real-MySQL integration test proves apply + derivation across every year class, no-op replay through the guards, and hostile post-apply writes. Valid-year ordering semantics are unchanged — no wire codes, no API-payload shape change, no status semantics moved. - **HTTP copy law pass one: the auth lock-out 422 now speaks customer instead of log-line, and eight bare status phrases became descriptive domain copy (server minors batch W-min100, part 1).** - **M-SERVER-05 (cured — this row shipped mislabeled `M-SERVER-02` in the part-1 commit; relabeled here because the W-min101 `M-SERVER-02` `Missing X`-family item is a different ask, untouched and still scheduled):** `AuthMethodLockoutException::publicMessage()` is the new HTTP arm of the auth-toggle guards — `At least one sign-in method must remain enabled` and `Disabling these auth methods would leave N active administrator[s] with no usable sign-in factor` (count spelled, never `(s)`). The `auth.method_lockout:`-prefixed sentence stays exactly where it belongs: the log line (`getMessage()`, `AuthMethodPolicyTest` pins untouched). `AdminSettingsController::update()` and `AuthProviderController::disableProvider()` 422 arms (`errors` values + `message`) switched to it; the `reason` token remains the machine discriminator; blocked-admin user ids never ride the wire (count only, as the class docblock already promised). `docs/dev/AUTH_METHOD_LOCKOUT.md` re-stamped to the two-tier truth; the envelope test's pin that HAD frozen the log string into the contract flipped to the public copy and gained prefix/`(s)`/id absence pins plus singular and plural R2 arms. - **M-SERVER-12 (cured):** eight bare status phrases became the house descriptive family. SessionController: `Unauthorized` ×4 → `Authentication required` (the 6× precedent the review cited), `Forbidden` ×4 → `You do not have permission to access this session`; MediaItemController chapter-thumbnail arms: `Not Found` ×4 → `Chapter not found`; the DASH/HLS job-file oracle pair (`invalid request`/`Not found`) in DashController AND TranscodeFileServer became `Invalid request`/`Segment not found` — BOTH arms together, so the rating-cap deny stays byte-indistinguishable from a genuinely-absent file; the `Service Unavailable`/`Service unavailable` casing pair unified to sentence case. The `Response` status-text map and the SyncPlay wire-protocol `error` payloads are not envelope copy and were left alone. Consumers render `error` verbatim and discriminate by code — no wire codes, no schema, no status semantics changed; the one test pin (SessionControllerTest `Forbidden`) flipped with the cure. - **The data-model catalog now really names EVERY first-party table the server ships — six missing live tables added and the completeness claim made checkable (server minors batch W-min96 — r2 rework of M-SERVER-16).** - **M-SERVER-16 (cured, r2 rework):** the r1 review of the W-min96 docs batch caught `docs/data-model.md` shipping 76 catalog rows against 82 first-party tables while its header claimed "Every first-party table the server ships" — a false enumeration. Re-measured from scratch at tip `26bc3bf6`: the distinct `CREATE TABLE` targets across `migrations/*.sql` number 82, no `DROP TABLE` exists anywhere in the set, and exactly six tables had no row — `media_extras` (007), `newsletter_queue` (020), `backups` (021), `server_settings` (026, the entire admin-settings store), `library_scan_jobs` (027) and `maintenance_jobs` (098). All six added in the house one-line-per-table format at their migration-number positions, each naming its real consumer class (verified on disk); a new "Completeness" reading note pins the 82 = 82 derivation so the next edit must re-run the scan, not inherit the claim. Documentation only — no schema, DDL or behavior change. - **Docs truth pass: the README gains a symptom-first Troubleshooting runbook, the WebSocket event catalogue is completed and re-pointed at its real file, three env/flags/metadata claims are cured, the auth rows state their actual wire shape, the tracker-ID shorthand gets a legend, and the British-isms sweep lands (server minors batch W-min99: 8 cured).** - **M-SERVER-45 (cured):** new `## Troubleshooting` section — six symptom entries (web playback buffering → ffmpeg/HLS tunables and coroutine-abort log signature; stuck SyncPlay group → the joiner-gets-`syncplay_group_state` re-synchronisation path; missing Live TV recording → the admin DVR endpoints/tuner-health/storage checks; cast device not found → per-protocol enumeration routes, boot-time discovery listeners, `CastingEnabledMiddleware` gate; webhook never fires → the legacy `/api/v1/admin/webhooks` CRUD+test surface vs the V2 `/subscriptions`+`/deliveries` subsystems and their tables; admin lock-out → pointer to the guard section and CLI recovery). Every route, table, log path and env knob cited was verified against `Application.php`/`AdminRoutes.php`/`config/logger.php` on disk. - **M-SERVER-60 (cured):** the tree listing and the `MessageHandler` prose in `README`/`AGENTS.md`/`CLAUDE.md` named a long-deleted `Events.php`; all now cite `WebSocketEvents.php`, and the README's event list grew from 14 entries to the full 26-constant catalogue (session, general and syncplay-request events were undocumented), descriptions taken verbatim from the constants' docblocks. - **M-SERVER-63 (cured):** the auth table's `503 provider_not_configured`/`callback_url_not_configured`, `409 last_sign_in_method` and `409 not_configured` rows now state what the clients actually parse — the token is the JSON body's `error` field (plus provider `name` for the enable/disable 409), with no `code` key — matching the emit sites in the OIDC/GitHub callback, account-link and auth-provider controllers. - **M-SERVER-62 (cured):** the metadata feature bullet names its two live admin-editable knobs: `metadata.cache_ttl_hours` (default 24 h) and `metadata.min_match_confidence` (default 0 = gate off), replacing the hardcoded-sounding "24-hour cache" claim. - **M-SERVER-57 (cured):** the `--no-proxy` install-flags row now carries the law the installer actually enforces: non-interactive runs must pair it with `--i-know-plaintext-internet-facing` (interactive runs get the typed-yes prompt instead). - **M-SERVER-31 (cured, docs-only by decision):** the systemd unit's comment claimed `/var/log/phlix` was the logs path — it is not; the server writes its app logs under the install dir's `.logs/` and sends stdout to the journal. The grant stays (the installer provisions the dir for operator/placed log output and uninstall artifact-detect scans it — removing it would be a behavior change with no gain), and both the unit comment and `systemd/README.md` now state the real relationship. - **M-SERVER-93 (cured):** the estate tracker shorthand (`R14-05`, `S152`, `SV-4.15`, `updates.md #47` …) that peppers the README and code comments gains a one-paragraph legend at the top of the environment-variable census: internal review-campaign ticket IDs, provenance for maintainers, not resolvable from this repo. - **M-SERVER-95 (cured):** Americanized the doc set — `behaviour`/`behavioural`→`behavior`/`behavioral`, `artefacts`→`artifacts`, `licence`→`license`, spaced `100 %`→`100%` across `README.md`, `TRADEMARK.md` and `docs/dev/BLOCKING_IO_EXCEPTIONS.md` (`CHANGELOG.md` history stays verbatim per house law). - **Wrong-method requests on existing paths now answer 405 with an `Allow` header, and the ops docs' MySQL floor, env census, helm key, helm install path, php.ini honesty, queue cap, and secret-key comments are cured to shipped truth (server minors batch W-min98: 8 cured).** - **M-SERVER-52 (cured):** `Router` previously answered every dispatch miss with 404, so `DELETE` to a GET-only path was indistinguishable from a typo'd one. The three dispatch miss sites now answer **405 Method Not Allowed** with an `Allow` header when the path is registered under some other method (RFC 9110 §12.4.6), keeping the 404 for genuinely unknown paths. `Allow` enumerates both route maps in canonical verb order; `HEAD` joins whenever `GET` is allowed (the GET→HEAD fallback really serves it); `OPTIONS` is only listed when registered. The envelope stays the flat `{error, message}` JSON shape — no new wire error codes. Because the portal table and the page routes are SEPARATE route spaces from the Application router, `HttpHandler::__invoke()` and `RelayRequestDispatcher` now treat a router 405 as a fall-through signal exactly like 404 (GET `/api/v1/media/{id}` must reach its portal route even though the Application table registers `DELETE` there; GET `/auth/register` keeps its page redirect even though the Application table registers the POST), and only re-prefer the 405 when the second table misses too. Nine unit pins in `RouterMethodNotAllowedTest` (static/parametric 405s, Allow order, envelope shape, bare-OPTIONS flip, head-framed 405); the Application-router wire-path guard's mutated-spelling control moved 404→405 with the boundary documented in place. - **M-SERVER-13 (cured):** the README requirement now states the REAL floor — MySQL **8.0.13** (expression defaults like `DEFAULT ('[]')` in migration 012 need it; MariaDB has allowed expression defaults since 10.2) with latest-8.0-patch guidance — and `111_casting_sessions.sql`'s schema note dropped its MySQL-5.7 claims for the honest house-style wording. - **M-SERVER-24 (cured):** the environment-variable census gained the four names it was missing: `PHLIX_RELAY_ALLOW_PLAINTEXT` (M5 warning-ack; REQUIRE_TLS still wins), `PHLIX_THEME_MUSIC_ENABLED`/`_SOURCE`/`_TIMEOUT` (`config/theme_music.php`), and `USER` (`PluginLoader` diagnostics fallback, ext-posix absent only). The batch's fifth name — a lone `USERNAME` env read — is DISPROVED: the cited `UserListCommand.php:139` occurrence is a table-header literal; the real `DB_USERNAME`/`LASTFM_USERNAME` reads were already censused. - **M-SERVER-27 (cured):** the helm ConfigMap key `PHLIX_APP_ENV` (zero readers) is renamed to `PHLIX_ENV`, the name `AuthServicesProvider` actually reads — matching the systemd unit and installer template. - **M-SERVER-30 (cured):** `k8s/README.md` no longer leads with `helm repo add https://charts.phlix.media` (a repository that does not exist); local-chart and release-asset installs lead, and the repo-add line is demoted to a disabled-future note — matching `RELEASE_PROCESS.md` ("charts ship only as release assets"). - **M-SERVER-37 (cured, comment-only):** `docker/php.ini` now says the 100M `upload_max_filesize`/`post_max_size` lines bound NOTHING in the container — the daemon parses raw HTTP and the enforced request-size cap is Workerman's 10 MiB per-connection package size; no runtime value changed. - **M-SERVER-64 (cured):** the README Playback-Queue bullet names the real cap (1000 items, `GroupState::MAX_QUEUE_SIZE`) and the `syncplay.queue_limit_exceeded` code an over-cap add returns — both long since shipped. - **M-SERVER-70 (cured):** both `scripts/install.sh` `PHLIX_SECRET_KEY` comments dropped the false "HMAC-signed cookies / read by the code via getenv" story: zero PHP reads the name; the docker entrypoint maps it onto `JWT_SECRET` and native installs use `JWT_SECRET` directly (census-pinned truth). - **`RATE_LIMIT_*_MAX=0` finally means deny: config env reads centralize on a typed reader that preserves the literal "0", plus config/changelog/haproxy/README doc-truth passes (server minors batch W-min97: 8 cured).** - **M-SERVER-34 (cured):** new `src/Common/Env/EnvConfig.php` (`integer`/`text`/`nullableText`) centralizes the numeric- and string-knob env reads across 11 `config/*.php` files — unset and empty-string fall back to the default, but the literal `"0"` is PRESERVED, so `RATE_LIMIT_<surface>_MAX=0` (deny-all operator intent) no longer silently reverts to the permissive default. Boolean `filter_var` sites were left in their original `getenv() ?:` form per reviewer verdict (the ask is the numeric knobs; a blanket boolean rewrite would have changed security-adjacent posture on e.g. `PHLIX_RELAY_TLS_VERIFY=0`). 13 unit tests in `EnvConfigTest` pin the `"0"`-survival cure red-then-green. - **M-SERVER-28 (cured):** `docker/README.md` said the boot-smoke gate "makes 11 assertions"; the script's `EXPECTED_CHECKS` list is 20 — corrected, with a pointer naming `EXPECTED_CHECKS` as the authoritative list. - **M-SERVER-56 (cured):** the README environment-variable census gained the `PHLIX_DATABASE_*` alias family row (compose/helm set it; `docker-entrypoint.sh` maps it to the bare `DB_*` the PHP runtime actually reads) that was identical to the `DB_*` story yet absent from the table. - **M-SERVER-74 (cured):** `deploy/haproxy.cfg` binds the WS health-check frontend to `127.0.0.1:8098` (was `*:8098`, silently proxying external traffic straight to the WS worker past the firewall posture) and the stats block gained a one-line warning that `stats admin if TRUE` is unauthenticated runtime control — keep it loopback-bound. - **M-SERVER-90 (cured):** `## [Unreleased]` repeated `### Fixed` 17×, `### Added` 12×, `### Changed` 10×, `### Removed` 5×, `### Security` 4× — the batch closeout fold merged every type into ONE section (multiset-verified: all 7,333 content lines preserved, zero dropped); the `### Fixed (live production batch)` variant was folded into `### Fixed` with an in-place provenance note, and the load-bearing `### Known gaps (documented, NOT fixed)` qualifier was retained so it never reads as a fixes section. - **M-SERVER-91 (cured):** the `[SV-0.2]` section (an internal track-milestone label sitting in the SemVer sequence) and the missing-`[1.0.0]` story each got a one-line in-place note at their headings rather than a relabel — history stays verbatim per house rule. - **M-SERVER-92 (cured):** the two cited commit-shorthand leads (the deny-all cap pin and the SRV-HYGIENE batch) are re-led with the user-visible sentence with IDs demoted to a trailing parenthetical, and the CHANGELOG preamble now states the lead/ordering/fold discipline going forward. - **M-SERVER-97 (cured):** the ~145-line skip-set deep-contract chapter moved out of the operator README into `docs/dev/skipped-test-names.md` (its proper home); the README keeps a 5-line summary pointing there. The chapter's self-contradictory limit tally ("two closed / four remain" vs "three closed / three open") is corrected to the script header's canonical statement (KNOWN LIMITS 1–3 closed by S352, 4–6 open, plus limit 2's refusal side-effect), each remaining bullet tagged with its KNOWN LIMIT number. - **Admin settings type errors become human-readable sentences, and theming/CSP/endpoint docs are re-cured to shipped truth (server minors batch W-min96: 6 cured, 1 stale-verified, 1 contract-blocked).** - **M-SERVER-10 (cured):** the admin settings type-mismatch message was machine-only (`Expected type bool.`). It now names the expected and received value in operator terms via `AdminSettingsController::typeMismatchMessage()` (`Expected a value of type true/false — got number.`); the `MediaItemController` JSON-object body error and the `LibraryController` `autoCollections` hint are reworded to the same human register. Two pin tests rotated with the copy (`AdminSettingsSegmentFormatTest`, `AdminSettingsRealSchemaPutTest`). The `PluginAdminController` twin `sprintf` was left untouched — it is outside this batch evidence set (disclosed follow-up). - **M-SERVER-16 (cured):** new `docs/data-model.md` catalogs the migrated schema table-by-table with each table's creating migration, states the migration-numbering law (`glob` + lexicographic `sort`, gaps legal and never reused), and contrasts `collections` (005, user playlists) with `media_collections` (064, TMDB box sets); README gained the matching numbering-law paragraph. - **M-SERVER-32 (stale-verified, already cured at `5f57bd25`):** `systemd/phlix-server.service` already ships `ExecReload=/bin/kill -SIGUSR2 $MAINPID` with the Workerman graceful-reload rationale — the review's ask is satisfied on disk; no change. - **M-SERVER-61 / M-SERVER-65 (cured):** the README endpoint table is extended to the library-maintenance, casting-device, quick-connect, hub-token, OPDS and scrobbler-admin surfaces, the "Complete API" overclaim softened to point at `openapi.yaml` (the machine-checked bijection), and the `POST /auth/identities/link/hub` S301 route added to both the narrative and the table. - **M-SERVER-66 (cured):** the README SyncPlay section documents the three naming layers (wire `group` / internals `room` / user-facing `watch party`) so the mixed vocabulary is explained where an operator meets it; full one-noun consolidation stays an open owner decision. - **M-SERVER-68 (cured):** `AGENTS.md` and `src/Theming/TokenTheme.php` now state the CSP truth — `script-src` ships nonce-only (no relaxation) and `style-src 'self' 'unsafe-inline'` is DELIBERATE for the token-theme inline-`style` surface, citing `SecurityHeaders::contentSecurityPolicy()`. - **M-SERVER-88 (reported-blocked):** the "unify the error envelope to one shape everywhere" ask needs new/changed wire error codes and a response-shape contract change; the `@phlix/contracts` registry is byte-frozen this campaign (no new codes) so it is escalated as an owner-wave item, not mutated here. - **M-SERVER-41 r2 — the update-PUT leg gets the SAME path validation; the "single choke" claim cured to truth (review server__dba major):** r1 gated only `LibraryController::create()` and its prose claimed "`update()` never writes `paths`", so "this single choke covers every HTTP path into the column." False on disk: `update()` forwarded its raw body to `LibraryManager::updateLibrary()`, which JSON-persists `paths` whenever the key is present — a bogus-path PUT answered 200 "Library updated successfully" and stored the bogus list verbatim. Cure: the stat-check loop and the canonical `{error, code: 'invalid_request', invalid_paths: [{path, reason}]}` reject are extracted into ONE shared helper (`LibraryController::validateLibraryPaths()` + its envelope builder) called by both entry points. create()'s behavior is byte-identical across the extraction (its 5-test suite, the 78-test controller suite, and the contracts pair held green unchanged); update() now gates `isset($data['paths'])` — mirroring the manager's own persist condition, so an absent/null `paths` keeps the column untouched exactly as before — BEFORE anything is forwarded. Two update-only shape arms join the payload-reason vocabulary: `not_a_list` (non-array value) and `empty_list` (`[]` — the column-zeroing sibling of the create silent-drop hole); reasons are data, not wire codes — the @phlix/contracts registry stays byte-frozen (md5 40c1da48, zero new codes, no OpenAPI member moves). Prose currency (review issue 2): every "create choke"/"update() never writes `paths`" claim — the create() call-site comment, the r1 test's class docblock, the r1 CHANGELOG entry (corrected in place below, disclosed as a correction, history elsewhere verbatim), and the census staircase's "create choke" wording — is restated to the truth: create() and update() are the TWO validated HTTP entry points into the `libraries.paths` column, sharing one helper, grep-verified the sole production callers of their manager methods. Red-first by `tests/Unit/Server/Http/Controllers/ LibraryUpdatePathValidationTest.php` (8 tests, 48 assertions: bogus/file/non-string/mixed/empty-list/non-array all 400 with `updateLibrary()` pinned `never()`; valid-list 200 forwarding the validated list; paths-absent 200 untouched — the last two green both sides as regression guards; measured 8/12/6 red at 63ac756b). Census re-pinned from measured red on the rebased tree (landed atop the sibling M-SERVER-39f watcher lane's 1999→2000 pin): PHP files 2000→2001, declared writes 1078→1094 (+8 tests × userId+body stamps; the watcher entry added zero Request sites; zero src writes added). - **The directory checksum materialized the whole watched tree twice per poll — the hash now streams, and the entry list is bounded (review server__srphp M3):** `FolderWatcher::calculateDirectoryChecksum()` collected one `path:mtime` string per file into an array AND concatenated the same entries onto `$checksum` with `.=` before `md5()` — two full materializations per poll, an O(library-size) transient in the resident library-scan worker (the review sized realistic large libraries at 300–500k files). The cure streams the digest: after `sort()`, entries fold one at a time through `hash_init('md5')`/`hash_update()`/ `hash_final()`. Streaming a hash context over the same byte sequence yields the byte-identical digest of `md5(concatenation)`, so every checksum VALUE is byte-preserved — stored `lastChecksum`/`fileChecksums` baselines keep their meaning and nothing re-baselines. The array itself is now bounded: a `MAX_WATCHED_FILES_PER_PATH` ceiling (default 1,000,000 — 2x the cited scale; instance-settable via `setMaxWatchedFilesPerPath()` on the `setCheckInterval()` precedent) fails loud past the cap instead of growing the list unbounded, and `FolderWatchScheduler` already catches `Throwable` around both entry points with logging and registration backoff, so an over-cap directory is logged and retried — never OOM, and never a silently truncated checksum that would mis-detect every later change (that is why the cap throws rather than truncates). Proved red-first by `tests/Unit/Media/Library/FolderWatcherChecksumTest.php` (5 tests, 15 assertions: the byte-exact digest-contract pin — green on the parent by design, it is the no-re-baseline proof; a structural pin that the method body streams and carries no `.=` accumulator or `md5(`; over-cap fail-loud naming path and cap; exactly-at-cap clean plus one-past-cap rejecting, killing the boundary off-by-one both directions; and the shipped-default ceiling). All four mutants die (reinstating the concatenation reddens the structural pin ALONE — the behavior tests cannot see the absent copy, which is exactly why the pin exists; stripping the cap reddens 2; inverting the sort reddens 2; off-by-one-ing the cap reddens 2), and the 35 pre-existing FolderWatcher/FolderWatchScheduler/wiring behavior tests pass untouched — the independent digest-invariance proof. Census denominator rotated 1999→2000 from the measured phpunit red. - **"Add library" answered 201 for paths that do not exist — every path is now stat-checked synchronously at the shared controller gate (review server__webux M-SERVER-41; the update-PUT leg of the same gate landed in the r2 entry above):** `LibraryController::create()` validated presence and type but never touched the filesystem; `LibraryManager::createLibrary()` inserted the row and the background scan was enqueued blind. The bogus path surfaced only later as a `Scan path does not exist` warning in the server log plus a green-badge, zero-item completed scan — after the 201 whose message claimed "Library created; initial scan started in the background." Worse, the path collection loop silently DROPPED non-string entries, so `{"paths":[123]}` passed the `$paths !== []` presence guard and persisted a zero-path library (reproduced live: the red test at the old tip shows `createLibrary('Movies', 'movie', [], [])`). Now every entry of the multi-path field must be a non-empty string that exists, is a directory, and is readable; any offender rejects the WHOLE request with the canonical `{error, code: 'invalid_request', invalid_paths: [{path, reason}]}` envelope — `invalid_request` is the already-registered @phlix/contracts code this repo already REST-emits (QuickConnectController), so the registry stays byte-frozen and no OpenAPI enum member moves. `create()` is the sole `createLibrary()` caller; this r1 entry's further claim that "`update()` never writes `paths`, so this single choke covers every HTTP path into the column" was FALSE — the r2 review (server__dba major, entry above) found PUT-`paths` persisted unvalidated behind a 200, and the r2 cure gates BOTH entry points through the one shared helper. The DVR registrar's internal find-or-create seeds its path from a file it just wrote and is out of that surface. The `not_readable` arm is defense-in-depth for non-root deployments and cannot be pinned venue-independently (root reads chmod-000 dirs). Proved red-first by `tests/Unit/Server/Http/Controllers/LibraryCreatePathValidationTest.php` (5 tests, 42 assertions: bogus/file/non-string/mixed all 400 with the manager and job queue pinned `never()`, and two real dirs still 201); the 13 formerly fake-path happy-path fixtures in `LibraryControllerTest` (78 tests) and the real-MySQL auto-collections integration now run against real scratch dirs. - **CLAUDE.md prescribed the phpstan CLI override AGENTS.md bans, mis-stated the tests-gate level, and linked a deleted doc set (review server__writer M-SERVER-46):** the mirrored agent briefs disagreed with each other and with the live configs. `CLAUDE.md` told readers to run `phpstan analyze src/ --level=9` — since S256-residual the production CI step carries NO path and NO `--level` precisely because a CLI path overrides the config's `parameters.paths` entirely, silently shrinking the `src` + `scripts` corpus (level 9 per `phpstan.neon`) behind a green gate; it called the `phpstan-tests.neon` gate "level 2" while that config carries `level: 4`; and its "Further reading" plus one Conventions bullet still advertised `docs/dev/DEVELOPER.md`, the `docs/archive/` plans and technical-spec set, and the `PLATFORM_*.md` guides — all removed in `77e681fd` ("Do not link them as in-tree" per AGENTS.md). Cured to the AGENTS.md facts: bare `phpstan analyse` with the config-carried paths/level commented at the line, "(level 4)", Further reading trimmed to AGENTS.md's verified-present reference list, and the plans bullet rewritten to the retired-truth wording. Docs-only: no gate, config, or code moved (no test pins CLAUDE.md claims — the absence is disclosed in the lane fixlog). - **The README's restart-semantics prose finally puts TRUSTED_PROXIES in one camp (review server__writer M-SERVER-45):** "What requires a restart" listed `TRUSTED_PROXIES` twice — boot-frozen "by `RateLimitProfiles` pre-flight in `start.php`" in the restart bullets and "rebuilt per request" in the runtime paragraph — and the freezing claim was fiction on top of the contradiction: `RateLimitProfiles` reads no proxy env at all. Shipped truth (re-verified at this commit): `TrustedProxyResolver::configuredProxies()` reads the bare env per call and the constructor snapshots it; the REST path (`Request::getTrustedClientIp()`) builds a fresh resolver per request — live; the WS-connect path (`WebSocketServer::$trustedProxyResolver`) memoizes one resolver per worker — restart-scoped, the same class as the `PHLIX_SSRF_ALLOW_CIDRS`/`PHLIX_LIBRARY_ROOTS` per-process memos. `RATE_LIMIT_*` genuinely is boot-frozen, but by the config layer (`config/server.php` at config load + per-worker PHP-DI profile construction + the `WS_CONNECT` warm in `start.php`), and it never froze the proxy env. The cure is docs-only: the frozen bullet now states that mechanism and names only `RATE_LIMIT_*`; the runtime camp carries the REST/WS split; the env-table row keeps the legend column value (`live`, the REST-dominant path r1 endorsed) and discloses both paths in prose. `config/server.php`'s `trusted_proxies` key is its own self-declared introspection copy with zero readers estate-wide — untouched. Zero behavior change, zero new wire codes (registry md5 40c1da48 held). New `tests/Unit/Deploy/TrustedProxiesReadmeRestartClassTest.php` (9 tests/29 assertions, measured) pins the one-camp law, bans the fiction wording, and cross-checks the three code anchors so prose and code cannot silently diverge again — the house `BackupSubsystemReadmeTest`/ `SystemdReloadSignalTest` style. Red-on-parent measured: 5 of the 9 fail on the pre-cure README (the four code-anchor pins are README-inert by design). Census `EXPECTED_PHP_FILES` 1997→1998 (above the M40 rung), measured from the phpunit red. This is the item's third visit: the two earlier closeouts cited commits that do not exist; this one lands on master with the pin. - **The Trakt single-flight refresh registry is bounded and self-cleaning (review server__srphp M-SERVER-37):** `TraktApi::refreshAfterAuthFailure()` kept a method-local static map keyed by md5(refreshToken) whose SUCCESS entries were never removed — Trakt rotates the refresh token per grant, so each completed refresh left a dead key holding a plaintext `{access_token, refresh_token, expires_in}` array for the lifetime of the resident worker: unbounded growth plus permanent plaintext credential retention (the estate's resident-worker static-poison class), with cleanup wired only to the three failure paths. The registry also had no wait ceiling: a holder killed abnormally between claim and completion (e.g. Swoole cancellation skipping the `Throwable` cleanup) poisoned its key with a forever-`pending` marker the spin loop beside it would never abandon. The cure follows the `AuthManager::$rateLimitStore` shape — the registry became a structured private static property of `array{pending:bool, at:int, result:?array<string,mixed>}` entries; completed results are served and kept only for `REFRESH_RESULT_GRACE_SECONDS` (10), the whole store is capped at `REFRESH_CACHE_MAX_ENTRIES` (256) with sweep-then-evict-oldest before insert, and both classes of lapsed entry are pruned at every call entry so an idle worker sheds its plaintext tokens at the next touch. The Phase-1 wait is capped at `REFRESH_WAIT_MAX_ATTEMPTS` (400×5ms, mirroring the adjacent flock loop's existing ceiling) and throws rather than spinning forever, and a `pending` marker older than `REFRESH_PENDING_STALE_SECONDS` (30) is taken over, so a dead holder can no longer poison a token key. Single-flight semantics are preserved and pinned: a late waiter inside the grace window is served the completed result without a second POST, concurrent same-process flights still POST exactly once (real-coroutine drill), the cross-process `flock()` phase is untouched, and failure paths still leave no marker behind. New `tests/Unit/Server/Integrations/Trakt/TraktApiRefreshSingleFlightTest.php` (7 tests / 29 assertions, measured) — five eviction/cap/bounded-wait/takeover reds on the unbounded shape, two invariant pins green across the cure; the bounded-wait throw was itself mutation-proven (replacing it with a takeover `break` reddens exactly that test). Census `EXPECTED_PHP_FILES` 1995→1996, measured from the phpunit red. Zero wire changes, zero new files in src/. - **Browser auth errors/notices no longer vanish in the legacy redirect chain (review server__webux M1; M-SERVER-40):** the form flow 302s failures to the legacy aliases with the message in the query (`/login?error=…`, `/login?notice=…`, `/auth/register?error=…`), and `HttpHandler::dispatch()` then mapped those paths onto their `/app` SPA routes with literal targets — `Response::redirect()` sets `Location` verbatim, so the second hop dropped the query and wrong passwords, disabled signups and the pending-approval notice all rendered as "nothing happened". All 28 legacy page redirects (root, login/signup, library, media/player, search, settings, admin, music, books, audiobooks, photo) now go through one `HttpHandler::legacyRedirect()` helper that carries the incoming query — the re-encode via `http_build_query()` promotes dispatchPhoto's pre-existing inline idiom to estate law (the photo targets keep byte-identical carries; four duplicated locals collapse into the helper). A `&` separator guards the one reachable target that can already carry a `?` (the urldecoded music-name segments). The same death as the auth chain — `/search?q=` bookmarks, library filters — is cured identically; the vendored SPA does not yet *render* `?error=` on `/app/login`, which is a ui-repo seam, not a server drop. `HttpHandlerLegacyRedirectTest` (9 cases) pins the chain — 7 of them red on the parent; the Application `/auth/*` alias docblock that justified the routes via the long-retired `public/templates/auth/` Smarty templates is re-anchored to the live truth. Zero new wire codes (registry md5 40c1da48 held); census re-pinned from measured reds (PHP files 1995→1996 atop the M38 rung, declared reads 452→449 — four photo sites collapse to one — declared writes 1066→1068 — the new fixture's two stamps). - **The plugin artifact fetch now streams under named byte and time bounds (review server__srphp M-SERVER-38):** `HttpInstaller::downloadToTemp()` pulled the entire remote artifact into memory with one `@file_get_contents()` — no size cap and no named timeout (it silently inherited `default_socket_timeout`) — then made a second copy via `file_put_contents()`; a hostile/compromised release host answering with a multi-hundred-MB stream made that size resident on an admin-request worker (`install()` runs synchronously in the PluginAdminController path, ~1/14 of HTTP capacity per the blocking-I/O register), and the digest gate could only reject the content AFTER the full body was already buffered. The cure adopts the register's own Exception-4 idiom (`SsdpDiscovery::fetchOnceBounded()`): the fetch opens with an explicit `timeout` (`FETCH_TIMEOUT_SECONDS`, 30 s inactivity — named, not inherited), a declared `Content-Length` above `MAX_PACKAGE_BYTES` (64 MiB) is rejected BEFORE any body read, and undeclared (chunked) bodies are bounded by pumping `stream_copy_to_stream()` with a `MAX_PACKAGE_BYTES + 1` budget; every rejection releases the tempnam (S439 zero-residue discipline) and the pre-existing redirect/error exception messages stay byte-identical so the R20-02 sibling suite holds UNMODIFIED. New `tests/Unit/Plugins/Installer/HttpInstallerBoundedDownloadTest.php` (7 tests, live loopback `php -S` fixture mirroring the sibling venue): three rejection arms red-on-parent — the parent literally "successfully downloaded" the forged 100 GB-declaration as a 3-byte artifact and returned the 72 MiB unbounded stream as a stored file — plus the inclusive at-cap boundary, a `streamFetchOptions()` shape pin (deleting `timeout` reddens), a source-equality pin on the pump budget argument (unbounding it reddens), and a zero-residue ledger assert per rejection arm (dropping the overflow unlink reddens). Mutations M1 timeout-strip, M2 pre-check-strip, M3 uncapped-pump, M4 unlink-strip, M5 `>`→`>=` each killed by exactly one test. Zero new wire codes (registry md5 40c1da48 held). Census `EXPECTED_PHP_FILES` rotated from the measured phpunit red. - **The webhook signing law is reconciled to shipped truth — the production subscription path is now honestly documented as unsigned (docs-only; review server__seceng M34):** `WebhookEvent`'s canonical docblock declared "Every delivery carries TWO signature headers", but the server runs two delivery subsystems and only one of them signs. The `webhook_subscriptions` path — `WebhookService`, fed by EVERY production domain event through the PSR-14 bridge `WebhookEventSubscriber` (playback.*, library.updated, user.login/logout/created), created via `/api/v1/admin/webhooks/subscriptions` — has no `secret` column and sends only `Content-Type`, `X-Phlix-Event` and `X-Phlix-Delivery`: every live delivery is unsigned and cannot be authenticated by its receiver. The signed dual-scheme path is the separate `webhooks`-table registry (`WebhookDispatcher`, `/api/v1/admin/webhooks`), whose `dispatch()` has no production event caller in current code — the only live signed delivery today is the admin Test button. Five docblocks (WebhookEvent class + wire-format heading scoped to "dispatcher path", WebhookService class + its `emit()` example names that cited the retired `media.scanned`-era vocabulary, WebhookDispatcher::findWebhookById()'s ambiguous "signs identically to the subscription path", AdminWebhooksController's create surface, WebhookAdminController's registry) now state exactly what each path ships, including the receiver-side truth that a leaked subscription URL permits forged events. ZERO behavior change: no header, route, schema, status line or wire code moved; the open owner decisions (sign the subscription path; wire or merge the two subsystems) are recorded in the M34 fixlog. - **M32 residual truth pass atop the M5 reframe: the last "evaluated on request" fiction and two rotted README coordinates cured (review server__pm M-SERVER-32):** the sibling docs-cure landed while this lane's full dead-doc-strip alternative was mid-flight; two residuals survived it — `SmartPlaylistRefreshWiringTest`'s header still claimed a smart playlist "is evaluated on request" (nothing evaluates one on request; the only live evaluation is `CollectionManager::refreshSmartCollection()`), and README's smart-collections bullet cited `Router.php:872` (rotted to :887 by the reframe itself) and `start.php:1276` (rotted to :1290 by the M35 landing) — both reworded to durable, test-named anchors. The strip-vs-keep choice for the reframed helper itself is left to the review loop; the full-strip variant is preserved unpushed (fixlog `M-SERVER-32__fix.md`). - **`/api/v1/smart-playlists` was advertised by src/ docblocks yet registered nowhere — cured to the shipped collections truth and pinned (review server__pm M5):** `Router::smartPlaylists()` (a registration helper no boot path calls) and `SmartPlaylistController`'s class/method docblocks enumerated six REST routes as if served, and the scan-time handler/subscriber docblocks asserted `preview()` was "evaluated ON REQUEST … the only place a smart playlist's items are ever produced" — false on both counts. openapi.yaml carries zero smart-playlists paths (the bijection gate told the honest side) and README had already documented the helper as never-called; the lie lived only in code docs. Docs-only cure follows shipped truth: the live smart story is `POST /api/v1/collections/{id}/refresh` (owner-or-admin scoped) plus the scan-time `SmartPlaylistRefreshSubscriber`; the collections refresh path is re-anchored as the on-demand evaluator in both handlers and in the two test comments that had repeated the claim. The helper stays registered-nowhere (the README text cites it as existing-and-never-called) with its endpoint list reframed to "if called, the helper registers" — registering it for real needs a `created_by` migration and per-actor scoping first, since the controller has no ownership scoping at all and would serve an unscoped server-wide CRUD violating the collections ownership law; the new `tests/Unit/Playlists/SmartPlaylistRegistrationGuardTest` pins exactly that gated-decision posture: zero `->smartPlaylists(` call sites across src/ + start.php, no phantom route claims in the controller/handlers/provider, the unrouted markers present, and no smart-playlists path in the spec. Measured: 4 of 6 pins red at the parent (the two state-pins green by design); mutation probes — re-adding an endpoint table line reddens the controller pin, planting a call site reddens the helper pin; both restored green. Zero behavioral change; every touched test is mock-based, so no database was needed. - **The WS :8097 handshake now applies the same user-liveness law every HTTP request passes (review server__seceng2 M1, lane M-SERVER-35):** the handshake gate in `SyncPlayAuthMiddleware::authenticateConnection()` was crypto + `type === 'access'` + string-`sub` only, and its own docblock disclosed the gap as *deferred* on the premise that "no UserRepository/DB handle is injected at the :8097 fork" — disproved on disk: `start.php` §4a builds the full container inside that fork and already resolves `UserRepository` there. A disabled or deleted account's access token, or one at/before the user's token-revocation watermark (logout-everywhere, password change, admin bump), therefore authenticated `:8097` for the token's whole TTL while the identical token was refused by every HTTP request through `AuthManager::validateAccessToken()` (`src/Auth/AuthManager.php`, consumed at `RequestAuthenticator.php:77-78` — S1 active-only, H-1 deleted-null, M-1 watermark-vs-iat, 5s cached-state cadence). The cure is identity-of-code-path, not a re-implementation: the middleware gains an optional `AuthManager` seam (third constructor parameter; null keeps the prior crypto-only law for direct test/dev construction), the wired handshake additionally requires `validateAccessToken($token) !== null` for the very same credential, and `start.php` supplies the container's `AuthManager` — resolved unwrapped so a container failure fails the WS boot loudly rather than silently downgrading the gate. Refusals take the existing uniform false→pre-101-close path (`WebSocketServer::onWebSocketConnect`): zero new wire codes, registry md5 40c1da48 untouched. Honest bound documented in the gate's docblock: liveness is checked at the HANDSHAKE — a connection accepted while live and deactivated afterwards stays open until disconnect (HTTP re-checks per request; an open socket has no next request to gate); a revocation reaper was deliberately NOT gold-plated, and the cheap per-message probe through the same cached seam is recorded as a rider proposal in the fixlog. Legacy `onConnect()` (deprecated, unwired) and `validateTokenStatic()` (instanceless public rail) stay crypto + access-type verdicts, now explicitly labeled as such. New `WsHandshakeLivenessParityTest` (7 tests/26 assertions) pins the four-state matrix with HTTP/WS verdicts asserted equal per row, uniform-refusal against the invalid-token path, and the active control; red-on-parent measured at cc2e9556 (5 failures — the parent silently ignored the seam and accepted disabled/deleted/revoked); a guard-branch mutation (liveness condition short-circuited) reddens exactly the parity rows and leaves the pre-existing R17-02 suites green. Gates: targeted WebSocket directory 107/410 exact, phpstan both corpora 0 errors, psalm zero new (90 pre-existing local swoole-absence errors, none in touched files), phpcs PSR12 src + phpcs-tests.xml + raw PSR12 on the new test all 0/0. - **The fabricated "no backup UI" claims are corrected to bundle truth, and the Backups-section rider of M22 is disclosed (review server__enduser M22 r2):** the r1 REWORK found the README Backups section — which rode `b865aa94` unannounced in that lane's plan and CHANGELOG entry, disclosed here — claiming "**there is no backup page in the `/app` SPA**", refuted by this repo's own vendored bundle: `BackupPage-*.js` is routed `{name:admin-backup, path:backup}` in the index chunk and its api module drives the full seven-endpoint surface (create, list, delete, restore, upload-s3, schedule GET/PUT). r2 re-verification caught a second instance of the same fabrication — the Overview bullet's "admin API + `bin/phlix` only (no SPA page)", introduced by the backup-docs cure `82d9f029` itself, in wording r1's survivor grep (searching the `b865aa94` phrasing) could not see. Both lines now state the truth (admin API + `bin/phlix` + an admin-only `/app` Backup page); every other claim in the section was re-verified and stands, and `82d9f029`'s substance (config table, endpoint rows, registration truth) is untouched — no duplication of that cure. The pin that had institutionalized the false claim (`BackupSubsystemReadmeTest`, added by `82d9f029`) is inverted: it positive-pins the truthful disclosure and NEGATIVE-pins both fabricated wordings so they can never return; the still-true `no restore path` pin and all seven route/default pins hold unmodified. Same review's CHANGELOG hygiene: the duplicate `### Fixed` header `b865aa94` added above the devops2 M3 entry is collapsed into one block, and the M22 entry's "showed exactly three of them" is corrected to the measured parent truth (three distinct commands in four invocations; `list` is a Symfony built-in, not one of the twenty). Docs + test-pin only: no runtime code, no behavior, no wire change; `EXPECTED_PHP_FILES` census untouched (zero PHP files added). - **`install.sh` aborted every run at the connection-budget step on a stray heredoc terminator — the measured cure of the M24b installer rider (review server__dba2 M24c):** the shipped text carried a bare `SQL` line directly after the SET-PERSIST block's `fi` (blame 8695fcda; three `^SQL$` lines but only two `<<SQL` openers pre-cure). It executes as a command — under the script's `set -euo pipefail` (:18) that is exit 127 "SQL: command not found" (reproduced minimally on the parent's block shape), aborting every install before the environment-file step; the grep-style content pins cannot catch a stray executable line. The cured bytes delete the stray line and carry the shfmt normalization rider: the shipped file is byte-identical to `shfmt` output of the de-strayed parent text, `bash -n` OK, and the `--help` contract byte-identical; `VersionMarkerFileTest|PublicFrontControllerRemovalGuardTest|AdminUpdatesRoutesTest` OK 25/220 on the new bytes. The entry below self-describes as "semantics zero" — this is its forward correction: the same bytes also remove the defect above, and this entry is the complete story. The response-code leg rides d70fcaae's psalm-safe dynamic-offset shape (`OpenapiSpecCurrencyTest` OK 6 tests / 10267 assertions). - **`scripts/install.sh` is reformatted to shfmt canonical shape — semantics zero (review server__dba2 M24b):** the installer normalization that rode the M24 shared-tree cure (prior fixlog classified it 22r2-rider lineage; the M24b dispatch attributed it to that landing, whose openapi/gate payload shipped separately byte-verified) is carried here: statement splits, per-line case arms, tab indent, aligned trailing comments. Semantic-null proven three ways at the rebase onto the M24 tip: `git diff --word-diff` shows only whitespace/statement-boundary churn, `bash -n` parses clean, and `install.sh --help` output is byte-identical to the pre-format file. The three test classes that READ the installer text (VersionMarkerFile, PublicFrontControllerRemovalGuard, AdminUpdatesRoutes — 25 tests / 220 assertions) pass unmodified on the new bytes. No behavior, no arguments, no defaults changed. - **The shipped Collections/Playlists feature gains its README documentation (review server__pm M28):** the whole CRUD surface — 11 registrations in `Application::loadCollectionRoutes()` (`src/Server/Core/Application.php:2209-2229`, incl. the `POST /api/v1/playlists` alias at `:2221`), the `created_by` ownership docblock at `:2173-2200`, the `112_collections_created_by.sql` migration and every OpenAPI path — shipped, yet README.md mentioned the feature nowhere: a member could not discover that lists are owner-scoped (`actorSees()` own+legacy-NULL, `actorManages()` owner-only with foreign/legacy rows admin-only), that every ownership miss is the byte-identical `404 {error:"Collection not found"}` of genuine absence by design (a distinct 403 would be an existence oracle), or that `POST /api/v1/playlists` aliases the same create handler. README gains a feature bullet, a "Collections and Playlists (member-owned lists)" section (CRUD loop, ownership law, 404-not-403 rationale, smart-collection refresh wiring, and the honest gaps: the unrouted `/api/v1/smart-playlists` helper in `Router.php:872` and the v0.99.10-pinned bundle serving only the admin Collections page), and 11-row coverage in the HTTP Endpoints table with per-registration line cites. Docs only — no code, no behavior, no wire change; the spec↔route bijection stays machine-checked by the existing currency tests. The phlix-docs operator page the review also asks for lives in that repo and is handed to closeout. - **The shipped nginx template 404ed every SPA asset; behind `install-nginx.sh` the web client never booted (review server__fedev M25).** `reverse-proxy/nginx/phlix.conf` carried a `location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$` block with `expires 1y` / `add_header Cache-Control "public, immutable"` and NEITHER `proxy_pass` NOR `root`/`try_files` — and the server block defined no root anywhere, so nginx resolved every match against its compiled-in default html root, where the files never exist on the proxy host (the Vite bundle lives in the server's `public/assets/app/**` and only the :8096 Workerman worker knows it, `start.php:194` + `HttpHandler::serveStatic()`). The SPA shell (`/app`, no matching extension) loaded, then every hashed chunk — `.js`, `.css`, `.woff2` — died at the proxy; the legacy `public/assets/{css,images,js}/**` statics 404ed the same way. `nginx -t` (install-nginx.sh's only gate) stayed green because a dead location is still valid syntax; the Caddy sibling never carried the fiction. Cure: DELETE the regex location — assets now proxy through `location /` and the caching intent is satisfied UPSTREAM, scoped correctly: `serveStatic()` tags ONLY realpath-jailed `/assets/app/**` with `Cache-Control: public, max-age=31536000, immutable`, which the proxy forwards untouched (re-adding it at the edge on the broad extension set would pin hashless server-generated images to a year of `immutable`, and a regex location outranks the `/media/` and `/api/` prefixes forever after). New `tests/Unit/Deploy/NginxAssetLocationTest.php` pins the shape against the REAL shipped artifact (every location body must be actionable; the M25 extension set may not return; the upstream immutable tag — the deletion's justification — stays coupled), red-on-parent measured for the three defect arms before the edit. `nginx -t` verified via docker nginx:1.27-alpine on a copy with only the letsencrypt cert paths rewritten (the shipped file cannot `-t` without real certs — install-nginx.sh's own `-t` runs post-operator-edit). Docs truth: the `reverse-proxy/README.md` feature bullet "Static file caching" claimed edge caching the nginx config never performed — rewritten to the cache pass-through truth with the authority citation. Census: `EXPECTED_PHP_FILES` 1989→1990 (the one new test file, re-pinned from measured red). - **`bin/phlix migrate` now bootstraps the managed env file exactly like its documented twin (review server__ipd M2, M27):** the README promises the two migration commands are "the supported equivalent" of each other, but only `scripts/run-migrations.php` required `scripts/bootstrap_env.php`, so on an install.sh-managed box — where the only copy of `DB_PASSWORD` lives in `/etc/phlix/env` — the primary documented command failed with "Access denied … (using password: NO)" while the script succeeded (the failure mode `bootstrap_env.php`'s own header names verbatim; the CHANGELOG M26 entry deferred this asymmetry to its own item). Cure is the S159 finding-11 precedent: fix the CODE, not the promise — `bin/phlix` gains `require_once BASE_PATH . '/scripts/bootstrap_env.php';` directly after the autoloader require, before every `getenv()` site in the file, which also lifts the same trap off every other DB-touching CLI command. The require is a strict no-op when `DB_PASSWORD` is already set or no env file exists, and opens no socket, so the "`php bin/phlix list` works with no database" story is untouched. NEW `tests/Integration/Common/Database/ CliEnvBootstrapParityTest.php` proves the parity behaviourally: two real subprocesses, env scrubbed of `DB_PASSWORD`, a deliberately WRONG password fed only via `PHLIX_ENV_FILE`, an empty-handshake probe migration forcing the pool's lazy lease to dial — the server's `(using password: YES/NO)` bit is the observable; both children must say YES and exit 1 (S159 parity). Red-on-parent measured: `bin/phlix` leg FAILS with "(using password: NO)" verbatim while the script control passes; at cure OK (2 tests, 12 assertions). README carried two scope-truth amendments (the M26-era durable-service paragraph scoped `bootstrap_env.php` to "scripts under `scripts/`"; the equivalence sentence now states the shared env-file bootstrap explicitly). Census re-pin measured from the phpunit red: `EXPECTED_PHP_FILES` 1988→1989; `EXPECTED_ADOPTERS` stays 73 honestly (the count pins in-class `RequiresRealDatabase` trait use; the new test uses the guard directly, the `MigrationFailureVisibilityTest` precedent). No new wire codes, no migrations. - **The shipped theme selection surface and its user-visible laws are documented to code truth (review server__pm M29):** the catalogue endpoints (`GET /api/v1/themes`, `GET /api/v1/themes/{id}` — registered `src/Server/WebPortal/WebPortalRouter.php:416-417` inside the `AuthMiddleware` group closing :448, served by `src/Server/Http/Controllers/ThemesController.php`) and the three laws riding on them — write validation against `BuiltInThemes::IDS ∪ ThemeSourceRegistry` with fail-loud pre-write 400 (`src/Auth/UserRepository.php` `validateThemeId()` :903, wired at :824; IDS literally `['nocturne','daylight','midnight']`, `src/Theming/BuiltInThemes.php`), the hard no-clearing v1 law (`UserRepository.php:792` "CLEARING IS NOT SUPPORTED (v1 law)" — no API resets `active_theme_id` to NULL; `null` 400s like any unknown id; `GET users/me/settings` omits the key until first choice :767-771), and the install→**enable**→select flow whose enable-step drives registration (`PluginLoader.php:527` register into the container-singleton `ThemeSourceRegistry`; all-or-nothing fail-closed) — existed in zero human-readable place: at 894ede3e `grep -rn "api/v1/themes|nocturne|CLEARING" README.md docs/` returned nothing, and the estate's "installed a theme plugin and nothing changed" symptom (theme-plugin-fix campaign) was exactly this doc hole. README gains a "Themes (choosing an appearance)" Features section (auth posture incl. the fingerprinting rationale, wire shape and the load-bearing `builtIn` flag, deterministic order and no-shadow guarantee, the selection contract with the fossil pre-S85 `phlix-*` names now 400, the write-once NULL semantics with the playback-preferences non-touch note, the three-step plugin flow, and the stored-then-disabled id edge, said honestly), an Overview bullet, and four HTTP-endpoint table rows (the two theme GETs plus the previously unlisted `GET`/`PUT /api/v1/users/me/settings` with their theme semantics). `openapi.yaml` is the spec's own surface and was NOT touched here (live-lane turf); this is the prose home for the laws. Docs only: no code, no behavior change; no README-claims pin test exists for this family at this tip and none was added (M22/M23/M26 docs-rider precedent; the lone `BackupSubsystemReadmeTest` pins the backup family only). Provenance: the prior M29 lane was a phantom-closeout (no commit, no report — `M-SERVER-29__r1.md`); this is the real first cure. - **The "Manual install (from source)" walkthrough is documented to completable truth (review server__ipd M26):** the block could not survive contact with its own server — three independent stops, each verified at this tip. (1) Nothing provisioned the database the shipped defaults expect (`config/database.php:16-17` default database/user `phlix`/`phlix`), and a failed connect propagates out of `MigrationRunner::run()` as an uncaught driver error (`src/Common/Database/ MigrationRunner.php:304-305`) with no remediation prose anywhere on the path. (2) `DB_PASSWORD=… php bin/phlix migrate` scoped the password to that one command; the very next line's `php start.php start` then read `getenv('DB_PASSWORD') ?: ''` (`config/database.php:18`) and connected with an empty password. (3) `JWT_SECRET` was never mentioned although `start.php:57-63` hard-refuses to boot without it (`AuthServicesProvider::assertSecretConfigured()` → `exit(1)`; empty / shipped sentinel `default-secret-change-me` / publicly-known placeholders fatal on every path, the 32-byte floor fatal only outside `PHLIX_ENV=dev`). The block now carries a MySQL provisioning step mirroring the create-half of `scripts/install.sh:1927-1934` verbatim (utf8mb4_unicode_ci, both `'phlix'@'127.0.0.1'` and `'phlix'@'localhost'` users — the reverse-DNS host-match story the installer's own comment documents — and the exact grant list), replaces the per-command prefix with `export DB_PASSWORD=… JWT_SECRET=$(openssl rand -hex 32)` and states why, and closes with a durable-service paragraph: `install.sh` persists the values in `/etc/phlix/env` (`EnvironmentFile=`), the PHP runtime never reads that file by itself (`scripts/bootstrap_env.php` covers the `scripts/` maintenance tools only), and the environment-variable census remains the SSOT — its own "Most operators only set `DB_PASSWORD` and `JWT_SECRET`" line is now actually true of the walkthrough that sits above it. Docs only: no code, no behavior change; no README-claims pin test exists for this block at this tip and none was added (M22/M23 docs-rider precedent). The `bin/phlix` vs `run-migrations.php` env-bootstrap asymmetry (ipd M2) is a separate queue item, untouched here. - **The signup-approval household workflow is documented to shipped truth (review server__enduser M23):** the shipped default is an approval queue — `config/auth.php:31` declares `'signup_mode' => 'approval'` and `AuthManager::resolveSignupMode()` fails safe to it — yet the README never said so: the register endpoint was one bare "User registration" row and the admin approval surface (`POST /api/v1/admin/users/{id}/approve|disable|reject`, `src/Server/Http/Routes/AdminRoutes.php:276-278`) was absent from the API table entirely, while the sibling rows for set-admin/reset-password were listed. A household admin following the README could not discover that a new member lands `status='pending'` with `202 {"status":"pending","user":null}` and no tokens, cannot log in until approved (`403` `auth.account_pending`), that the first registered user always bootstraps as active admin regardless of mode, that `auth.signup_mode` is a live-re-read admin setting (`open`/`approval`/`disabled`; `disabled` → `403` `auth.signups_disabled`), or — honestly — that first logins via OIDC/GitHub/LDAP auto-create **active** accounts outside the gate (`UserRepository::findOrCreateByExternalId` INSERTs without `status`, column default `'active'` per `migrations/037_users_status.sql:38`). README gains a "Signup approval gate (adding household users)" subsection (with a one-line pointer from Authentication & Security and a cross-reference — not a re-tabulation — into M22's existing CLI triage section), the register row now carries the 201/202/403 gate semantics, and the three approval endpoints join the admin user-management rows with their measured response law (`approve`/`reject` 404-unknown, `reject` 400-not-pending, `disable` 400 self/last-admin; disable/reject revoke tokens, approve does not). Every claim cites file:line read at this tip; nothing claiming open signup existed, so nothing was corrected — the defect was invisibility. Docs only: no code, no behavior change; no README-claims pin test exists at this tip and none was added (M22/M19 docs-rider precedent). - **README documents the backup subsystem to shipped-code truth (review server__enduser M-SERVER-21):** the subsystem had no Features section and its seven admin endpoints were absent from the API table. The README now carries a dedicated "Backups (admin-managed)" section (every knob of the standalone `config/backup.php` with its shipped default; archive contents incl. the recursive-`config/` capture and the `MYSQL_PWD`-only / `-- Dump completed` dump-integrity contract; count-based retention after every create; the background-timer worker's 5-min boot catch-up plus idempotent daily check; checksum-gated synchronous restore; S3 upload row-rewrite), seven HTTP-endpoint table rows, CLI usage lines, and an Overview bullet. Honest limits disclosed as such: no `/app` SPA page, `data/`/SSL files are archived but have NO restore path, `expires_at` is listed but never written (no time-based expiry), no public endpoint pulls an S3 row back, a schedule PUT takes effect for the running timer only at the next restart, an uploaded archive's local copy stays on disk untracked-by-retention, and the seven routes are registered twice with the later `AdminRoutes` entries winning the Router's method+path map. Pinned by a new `tests/Unit/Admin/BackupSubsystemReadmeTest.php` (9 cases): each documented route path must appear in the README AND as a real `AdminRoutes.php` registration, documented defaults must equal the shipped config values, and the two restore/surface disclosures must survive edits. Measured red on `b865aa94` (4 failures — the endpoint rows and the schedule paths it cross-checks), green at this tip. Landing provenance: the section's prose itself landed inside `b865aa94` (the M22 lane staged the shared `README.md` mid-edit); the endpoint-table rows, the Overview bullet, the corrected router-registration paragraph, and the pin land in this commit — the double-landing counts the work once, not twice. - **The operator admin-CLI story is documented to shipped-surface truth (review server__enduser M22):** `php bin/phlix` ships twenty commands (`UserResetPasswordCommand`, `UserPromoteCommand`, `UserCreateCommand`, `User{List,Approve,Reject,Disable,Delete}`, `Plugin{List,Install,Enable,Disable,Uninstall}`, `Backup{Create,List}Command`, `Library{List,Scan}`, `MediaDedupePaths`, `Migrate`, `HwaccelProbe`), and the README CLI section showed three distinct commands in four invocations (`migrate`, and `media:dedupe-paths` with and without `--apply`, plus the built-in `list` — which is not one of the twenty) while closing with "More commands are added in later steps." — prose the later steps had long since left stale. An operator locked out of the web UI (forgotten admin password, auth-method settings) had zero in-tree pointer to `user:reset-password`, which needs no admin session — it is a one-shot process reading the configured database directly. README's CLI section now carries the full command table with real arguments/options (verified by reading every command class and by running `php bin/phlix list` at this tip) plus a "Bad days: recovery from the CLI" subsection: forgotten admin password (with the honest caveat that resetting the hash does not re-enable a disabled auth *method* — the CLI exposes no settings toggle), first-admin bootstrap (`user:create` then `user:promote`; there is no admin flag), plugin rescue, signup-queue triage, and backup-before-surgery — every claim citing a command that exists; non-existent flags (`user:reset-password --json`, a settings toggle, a last-admin bypass) are named as absent rather than invented. Docs only: no code, no behavior change, no pin test for README CLI prose existed or was added. - **Production examples and the installer raise MySQL `max_connections` against the documented pool demand (review server__devops2 M3):** the pool math is honest in code (`config/database.php` + `ConnectionPool.php`, R19-16: 14 HTTP workers × `DB_POOL_SIZE` 8 = 112, plus the four count=1 pool workers, CLI and co-tenants — ≈140 at defaults) but NOTHING shipped raised MySQL 8's stock ceiling of 151 to meet it: every production-example MySQL service ran bare `mysql:8.0`, and `max_connections` had zero hits across `docker/examples/`, `docs/` and `k8s/`. The wall is met as a `Too many connections` 500-storm exactly when the server is busiest. Cure, at the three surfaces where production MySQL is actually configured: (1) all five example MySQL services (server-only ×1, server-hub ×2, full-stack ×2) now carry `command: --max-connections=300` (~2× documented demand) with the math in a comment; the root `docker-compose.yml` stays untouched — it self-labels as dev, and the examples are the prod contract (the review's own law). (2) `docker/examples/README.md` gains a "MySQL Connection Budget" section carrying the worker×pool formula and the bring-your-own-MySQL `SHOW VARIABLES` instruction. (3) `scripts/install.sh` attempts `SET PERSIST max_connections = 300` right after its existing root-socket grant block (MySQL 8: online, survives restart, idempotent); best-effort with a loud warn so an external DB or a MariaDB box never fails the install. Pinned by a new tripwire beside the restart-policy pin in `DockerEntrypointTest`: in each example compose the count of `--max-connections=` raises must equal the count of `image: mysql:` services and each value must be ≥ 300 — measured red (count mismatch) on a stripped raise and red (floor) on a 200 probe, green after restore. - **`GET /api/v1/users/me/favorites` hydrates the page in ONE batched query (review server__devops2 M16):** `MediaUserDataController::listFavorites()` looped `ItemRepository::findById()` per favorite row — the repo's own Batch Queries law (AGENTS.md "Prevent N+1") broken in its most literal shape. With `limit` clamped 1-100 that was up to 100 sequential pool borrows + `media_items` queries per page inside a resident Workerman HTTP worker, and each `findById` additionally ran the P5-S2 profile tag filter (two more `profile_tags` queries per row whenever a profile is bound in `RequestContext`), so the true worst case was ~300 queries per page. The cure copies the sibling `MostWatchedController` shape exactly: collect the page's ordered ids → one `findByIds()` (`WHERE id IN (...)`, order- preserving, tag filter applied once for the batch) → re-walk the favorite rows against the id→item map. Output is byte-identical — both hydration paths funnel through `normalizeRow()` + `hydrateItem()`, and blocked/gone rows miss the map exactly where `findById` used to return null. Pinned two ways, each measured red at the parent commit: a controller-shape pin (`findByIds` once with the row order, `findById` never) and a query-COUNT probe running the REAL `ItemRepository` over a fake `Connection` — a 12-favorite page must touch `media_items` exactly once (it counted 12 before the fix); stripping the fix reddens precisely those four hydration tests. No SQL changed, so no new real-DB class and no IntegrationDbGuard delta; `findById` stays for the legitimate single-item write-validation paths. The AGENTS.md favorites sentence follows the new mechanism. - **`systemctl reload` now uses Workerman's GRACEFUL reload signal (review server__devops2 M15):** the committed systemd unit and the unit heredoc in `scripts/install.sh` still wired `ExecReload` to SIGUSR1 — the signal `AdminRestartController`'s own authority block calls "backwards": the Workerman master treats SIGUSR1 as the NON-graceful reload (children stop immediately, SIGKILL armed after stopTimeout) and only SIGUSR2 drains in-flight work. The admin Restart button had long since moved to SIGUSR2, so the documented operator path hard-cut the :8097 SyncPlay WS workers, in-flight HLS/transcode segment delivery and the hub relay tunnel on every bare-metal reload — including the live prod `update_server.sh` path. Both unit templates now emit `ExecReload=/bin/kill -SIGUSR2 $MAINPID` with an in-file rationale comment, and install.sh carries an idempotent `phlix_repair_exec_reload` migration (4i-B) that flips only the exact generated legacy value on already-deployed units, preserving operator customisations. The minimal installer was already covered — it installs the committed unit verbatim. Pinned by the new `tests/Unit/Deploy/SystemdReloadSignalTest.php`, whose repair proof runs the extracted function in real bash (CON-53-style), plus an authority-coupling pin so the deploy artifacts and the controller's docblock cannot diverge silently again. - **Vendored sample re-propagated after the inverse-role gloss (review theme-sample__writer M06 propagation):** upstream's payload gained a `--accent-text` / `--text-on-accent` teaching gloss (the pair the green sibling cohort misread) in its comment blocks only — re-vendored byte-identical here. Deliberately NO pin rotations this time: the golden regenerated to the same md5 (`5fd7fad5…`) because comments never reach the wire, which is the cross-repo pin discipline demonstrating it only bites on real payload movement. - **`--text-faint` raised from decoration-grade to AA ink, vendored sample re-propagated, golden re-pinned (review theme-sample__webux M04):** the standalone sample shipped `--text-faint: #465061`, which phlix-ui's `AuthField.vue` renders as `::placeholder` on the composited input background — measured 2.39:1 (2.49:1 raw on `--bg`), invisible-grade for a form affordance. Upstream raised it to `#6e7d94` (≥4.51:1 on all three composited input grounds, 4.85:1 raw) and gave the high-contrast variant its own `#93a1b8` (≥7.2:1), re-vendored byte-identical here. The `GET /api/v1/themes` golden regenerated (dusk still 28; high-contrast 8→9 — the second md5 rotation this week, which is precisely what the pin is for); `GOLDEN_MD5` rotated to `5fd7fad5…` in lockstep with phlix-ui's twin, and the token-count pin moved 36→37 ("28 + 9"). Built-in maps untouched (53 each). Upstream added a payload-computed WCAG law test so the floor cannot rot back without a red suite; its ground set is the roles the host actually renders (raw `--bg`/`--surface` + the 80%-surface input composite over all three page bases) — raw `--surface-2` is deliberately not asserted, as the value that would clear it would outrun `--text-subtle` and collapse the ramp. - **The vendored sample theme plugin was a stale fork of itself — re-vendored byte-identical to upstream and the cross-repo wire golden re-pinned (review theme-sample__webux M01, with M02's alias defect cured by the same propagation):** `examples/plugins/phlix-plugin-sample-theme/` drifted from the published `detain/phlix-plugin-sample-theme` in exactly the directions a copy rots — its `sample-dusk` payload still set the 9 dead `--color-*` aliases (37 tokens vs the upstream's measured 28; 12 of 13 have zero `var()` consumers in every shipped client), its docblock contradicted upstream's "omitted on purpose" law with "Omitting these would leave un-migrated components...", and its high-contrast variant lifted the DEAD `--color-text-secondary` instead of the live `--color-text-muted` — the alias choice the upstream payload teaches against (M02). The vendored file is now a byte-for-byte copy of upstream `src/SampleThemePlugin.php` (md5 equality asserted), the `GET /api/v1/themes` golden regenerated from the real controller (dusk 28, high-contrast 8, built-ins 53 unchanged) and the md5 re-pinned on BOTH sides of the cross-repo contract (this repo's `tests/Unit/Plugins/SampleThemePluginTest::GOLDEN_MD5`, phlix-ui's `src/api/themes.test.ts::GOLDEN_MD5`, twin fixture re-vendored). Token count pin 45→36 with honest prose; the pair's "unresolvable server-side because the server holds no stylesheet" comment corrected — the server DOES know every built-in's 53-token map and publishes it in the list; the wire just never flattens chains (two-sources-of-truth law, `ThemesController` docblock). `ThemeTokenAllowlist`'s LEGACY_ALIAS reason-2 was FALSE at its load-bearing point — "The published detain/phlix-plugin-sample-theme sets all 13 today" was never true (the standalone repo omitted them from its first commit; it was the vendored copy, fixed here) — rewritten to what actually holds the aliases in the grammar: the three `BuiltInThemes` maps, each declaring all 53 keys, whose wire body the golden pins; the stale companion cite (ui's pre-move test path) re-anchored. Examples README: the duplicated false stylesheet-only claim aligned to upstream's cured table, and the "install verbatim with `plugin:install examples/plugins/...`" sentence corrected — the CLI takes archives/catalog URLs, never a bare directory (`SourceUrlResolver` → `HttpInstaller::fetchInto()`); directory install is `PluginLoader::installFromDirectory()`, driven by the lifecycle test. - **M13 class-sweep survivors cured; the deploy.yaml code-edit recipe retired; bind-law docs re-sweep reaches zero survivors (review server__devops M13 r2):** the M13 cure (`bda69a35`) rewrote `deploy/haproxy.cfg`, `systemd/README.md` and `reverse-proxy/README.md` to the R14-06 truth, but its class sweep missed same-class survivors — its own "the literal `websocket://0.0.0.0:8097` no longer exists outside historical comments" claim was falsified by `deploy.yaml`, which still called the listening socket "hard-coded in start.php (~line 610)", asserted "these config keys do not move it", and told operators to "edit the start.php literal" — the exact code-edit recipe the cured haproxy.cfg header contradicts ("editing start.php is the pre-R14-06 recipe and is wrong now"). Cured here by mirroring that shipped law: `deploy.yaml`'s header, `internal_port` comment, exposure-posture note and `depends_on` manifest now state the `ListenEndpoints` resolution (env `PHLIX_WS_HOST`/ `PHLIX_WS_PORT` > `websocket.host`/`port` > shipped default 0.0.0.0:8097, resolved pair written back pre-fork; move the bind by env/config + restart, never a code edit) and the stale `~line 610` bind anchor is corrected to the real site (`start.php:636`, `ListenEndpoints::webSocket()`). Also cured, the two reverse-proxy passages r1 named: `reverse-proxy/nginx/phlix.conf` (header + WS note — "start.php binds (0.0.0.0:8096)"/"binds on 0.0.0.0:8097" → resolved defaults + env/config seams; the `count = 1` law stays) and `reverse-proxy/caddy/Caddyfile` (header "serves HTTP on 127.0.0.1:8096" — which additionally misstated the default HTTP bind host; `DEFAULT_HTTP_HOST` is 0.0.0.0, `ListenEndpoints.php:80` — now the resolved default with the loopback target stated as reach, not bind). Independent re-sweep found one more false live claim of the family: `docs/dev/settings-restart-gap.md`'s open-item "worker.count and process.reloadable are read by nothing — start.php hardcodes `$httpWorker->count = 14`" was CLOSED by R14-06 itself (pool size resolves env > `worker.count` > 14, `'auto'` = 14; `process.reloadable` applies via `ListenEndpoints::reloadable()`; `start.php:212-222` assigns the resolved `workerCount`) — the bullet now says so. Class re-sweep over every tracked deployment artifact (119 files: `deploy.yaml*`, `deploy/`, `docker/**`, `systemd/`, `k8s/`, `scripts/**`, `install/`, `docs/**`, `reverse-proxy/**`, `README.md`, `AGENTS.md`): zero stale-bind-law survivors. Remaining raw hits are the cured law's negations (haproxy.cfg:12), TRUE historical rationale (the helm hub chart's retired-`PHLIX_HUB_DATABASE_*`-names note — different subsystem), and the docker family's TRUE-AS-DEFAULTS port-only statements kept by the r1 precedent (`Dockerfile*` EXPOSE comments, `docker/README.md` and examples' "ports start.php binds" tables — port numbers correct, no bind-law claim); the `unix:` family is supervisord/docker control sockets and the SyncPlayBridgePublisher register only — zero daemon-bind claims. r1's cure entry above is kept verbatim per house precedent; this entry is its forward correction. Docs/comments-only — no runtime directive or PHP line changed; `deploy.yaml` re-validated with `yaml.safe_load`. No pin-test covers these docs (grep-verified: zero tests/.github/scripts content references to the four files; `settings-restart-gap.md` appears only in prose "See docs/..." test comments). - **`PHLIX_LOG_LEVEL` deployment-doc lie cured to the R3-08 code truth; the "Informational" env-sample comment retired (review server__devops M14):** `systemd/README.md`'s env sample told operators "log level is fixed in `config/logger.php` and the env-file values below are **not read by the current code**" over the live `PHLIX_LOG_LEVEL=info` / `PHLIX_ENV= production` rows. Both claims are false at HEAD: `config/logger.php:89-94` documents the override in its own header, and `StructuredLogger::envLevelOverride()` (`src/Common/Logger/ StructuredLogger.php:328-338`, `getenv` at :330 on `LogChannels::LOG_LEVEL_ENV` = `PHLIX_LOG_LEVEL`, LogChannels.php:107) replaces every level-driven handler's floor at :209 — vocabulary `LEVEL_NAMES` (:77-80, debug/info/notice/warning/warn/error/critical/ alert/emergency, case-insensitive), unknown value fails soft to the shipped floors, read at logger construction so a change is a restart operation. `PHLIX_ENV` is likewise read (`AuthServicesProvider.php:273` dev-shape tier, `:284` test tier; the main README env census rows 529- 530/664 already documented both as live). The stale sentence existed because the same comment once shipped inside `install/systemd.sh`'s generated-env template; that artifact was cured to truth in the M11 lane ("PHLIX_LOG_LEVEL is a live knob (StructuredLogger); PHLIX_ENV selects the dev/prod tier of the boot policy"), and the review's "two other deployment artifacts" (`scripts/install.sh:1849-1850`, `k8s/helm/phlix/ templates/{configmap,deployment}.yaml`) SET the knob precisely because it is functional — no stale prose in either. Docs-only rewrite: the comment now states the live-knob law, the PSR-3 vocabulary and fail-soft, the restart-not-reload semantics, the sibling `PHLIX_LOG_STDOUT` visibility knob (R14-09, config/logger.php:83-87/154-159), and that EnvironmentFile rows override the unit's `Environment="PHLIX_ENV=production"` default — removing the operator incentive to edit the deploy-tree `config/logger.php` and diverge the box from its package. Class sweep (grep "not read by" + "Informational" across docs/, systemd/, install/, scripts/, deploy/, k8s/, docker/): sole remaining hit was this block; `scripts/parallel/merge-junit. php:34` matched "Informational" as an unrelated test-name citation, and zero docker-compose/helm prose carries the false family — one site, one cure. No PHP touched; no pin-test covers the comment. - **Socket-bind documentation cured to the R14-06 config-driven truth; the haproxy runbook's code-edit recipe retired (review server__devops M13):** `systemd/README.md`, `deploy/haproxy.cfg` and `reverse-proxy/README.md` still carried the pre-R14-06 law — that `config/server.php` `server.port` "only drives the advertised/DLNA port", that "the sockets themselves are hard-coded in `start.php`", that `websocket.host/port` tune "the WS server object, not that listening socket", and (worst) that moving the WS bind means "change the start.php bind literal". All four claims are false at HEAD: `src/Server/Runtime/ListenEndpoints.php` resolves every service bind env > config > shipped default (`PHLIX_HTTP_HOST`/`PHLIX_HTTP_PORT`/ `PHLIX_HTTP_WORKER_COUNT` > `server.host`/`server.port`/`worker.count`; `PHLIX_WS_HOST`/`PHLIX_WS_PORT` > `websocket.host`/`websocket.port`; garbage throws `RuntimeException` naming the key), and `start.php` writes the resolved pair back into `$config` pre-fork (`start.php:212-224` HTTP, `start.php:636-647` WS) so advertised values always match the accepting socket. The literal `websocket://0.0.0.0:8097` no longer exists outside historical comments. Docs-only rewrite: the three stale passages now state the ListenEndpoints law, the systemd env sample gained the commented `PHLIX_HTTP_*`/`PHLIX_WS_*` seams, and haproxy Posture B is achievable with `PHLIX_WS_HOST=127.0.0.1` — never a code edit. Honest residuals kept: the shipped defaults (0.0.0.0:8096/8097, pool 14; `worker.count 'auto'` = 14) are stated as defaults not promises of environment, and the WS worker's `count = 1` stays fixed by law (single authoritative SyncPlayManager) with no env seam — `PHLIX_HTTP_WORKER_COUNT` is HTTP-pool-only. Class sweep dispositions: `README.md`'s env table and `config/server.php` docblocks already held the live law (untouched); `docs/dev/settings-restart-gap.md` "hardcoded constants" (hwaccel `ShellTimeout`) and `README.md:963` (PHPUnit vendor printer) verified accurate — different claims, out of scope. No pin-test covers these doc files (grep-verified: zero test/script references to `systemd/README`, `deploy/haproxy`, `reverse-proxy/README`); no PHP changed. - **io_uring doctrine cured to the repo's own measurements — four prose sites and the flag table (review server__devops M12):** `docker/Dockerfile.base`'s S200 note is the canonical measurement record (io_uring is default OFF in the container via `ARG SWOOLE_IOURING=0`; where enabled, TWO stacked blockers are FATAL with exit 1 — Docker's default seccomp profile blocks `io_uring_setup/enter/register`, and Swoole 6.2.1's io_uring path rejects any kernel below 6.9 — with no silent epoll fallback once the flags are compiled in), yet four sites still repeated the refuted pre-S200 doctrine and one section pointed at the wrong file. Cured, prose-only: (1) `docker/README.md` "Swoole build flags" now attributes the swoole compile to `docker/Dockerfile.base`, not `docker/Dockerfile` (whose own header says editing it does NOT recompile extensions); (2) the two io_uring flag-table rows point at the RIGHT knob — the build-arg `SWOOLE_IOURING` (there is no runtime ini or env switch), not a "kernel 5.6+ at runtime" requirement; (3) the "io_uring caveat" paragraph replaced "silently fall back to epoll … dead code — they don't hurt" with the measured failure semantics and an honest recommendation (leave the default OFF; enabling requires BOTH seccomp permission AND kernel >= 6.9, and unconfined seccomp alone just swaps one fatal for the other); (4) both installers' comments (`install/systemd.sh` header + configure block, `scripts/install.sh` phlix_build_swoole note) supersede their "kernel >= 5.6 / fall back to epoll" sentences with the 6.9 floor, state the host divergence honestly (the seccomp blocker is container-only; the kernel floor is NOT), and `install/systemd.sh`'s false "Flags copied verbatim … source of truth" claim is re-worded to name the deliberate unconditional-io_uring divergence (matching the accurate S163 note its full-installer sibling already carried). No behavior changed anywhere: Dockerfiles and the configure blocks are untouched; the open code question whether the HOST installers should kernel-gate the flags below 6.9 is reported to the orchestrator as a rider, not silently decided. Historic CHANGELOG entries kept verbatim (house precedent — this entry is the fold-in). Docs + shell comments only; `bash -n` both installers, `composer validate --strict` OK, and the three file-parsing guards (`DockerEntrypointTest`, `ThirdPartyClonePinGuardTest`, `PublicFrontControllerRemovalGuardTest`) hold at 117 tests / 967 assertions. - **The minimal systemd installer now produces a BOOTING service, and the placeholder-signing-key guard is lifted from docker-only to every carrier (review server__devops M11):** the pre-M11 `/etc/phlix/env` template in `install/systemd.sh` (a) was written through a QUOTED heredoc (`<<'EOF'`) that could never expand a generated value and omitted `JWT_SECRET` entirely — the key `start.php`'s boot guard hard-requires — so a fresh install crash-looped forever against the unit's `Restart=on-failure`/`RestartSec=5s`; (b) wrote DB settings only as the `PHLIX_DATABASE_*` aliases, which `config/database.php` never reads; (c) shipped `PHLIX_SECRET_KEY` and `PHLIX_DATABASE_PORT`, which nothing on this carrier reads (`PHLIX_SECRET_KEY` is a docker-entrypoint input); and (d) its header claimed the live keys were "placeholders for the uninstaller and forward-compat". The template now writes the real `DB_*` keys, the uninstaller-read aliases (`PHLIX_DATABASE_HOST/NAME/USER` + `PHLIX_DOMAIN`, exactly the four keys `do_uninstall()` greps), and a generated 64-hex `JWT_SECRET` (`openssl rand -hex 32`, mirroring `scripts/install.sh`'s `rand_hex`), with honest comments; a `phlix_backfill_jwt_secret()` helper heals legacy files — a missing OR present-but-blank `JWT_SECRET` is backfilled once, idempotently, keeping the 640 `root:phlix` mode. Second half: the publicly-known placeholder family that `docker/docker-entrypoint.sh` has refused since S163 is now refused by the carrier-agnostic PHP boot guard too — a new tier in `AuthServicesProvider::secretViolation()` (fatal everywhere, dev included) rejects `change_me*`/`changeme*`/`change-me*` prefixes and the exact words `your-secret-here`/`your_secret_here`/`secret`/`password`/ `placeholder`/`example`/`test`, so a bare-metal host can no longer boot and sign real tokens with `change_me_generate_with_openssl` (31 bytes — invisible to the dev tier, and padded examples were invisible everywhere). The entrypoint guard stays as carrier-side defense-in-depth; a twin test pins the two pattern lists byte-for-byte equal (modulo the sentinel, which keeps its own dedicated tier message). Tests: new `tests/Unit/Deploy/SystemdInstallerEnvTest.php` (8 tests) parses the REAL heredoc and helper functions out of `install/systemd.sh` (CON-53-style pins): boot-required key set, uninstaller-reader preservation, no inert template keys, lie-phrases banned, the generator's output is 64-hex AND passes `secretViolation()`, legacy backfill is lossless and idempotent, `bash -n`, and one end-to-end child asserting `php start.php` itself refuses the committed placeholder with the CRITICAL envelope before any worker or port exists. Extended `AuthServicesProviderSecretGuardTest` with the placeholder tier, a padded end-to-end child, and the docker twin pin (+3 methods; the false-positive re-asserts joined the existing tiers test). Origin CI at this pair of commits: merge-junit tests=12708 assertions=118937 errors=0 failures=0 skipped=4 — exactly the parent's 12697/118815 plus this lane's +11 new tests / +122 assertions. Measured red-on-parent: 1 error + 9 failures across the 18 tests of the two files; green at head. `DockerEntrypointTest`'s S163 note forward-corrected to past tense; docker paths otherwise untouched. - **Release runbook: the "GPG Signing" section was fiction on every axis — replaced with shipped reality (review server__devops M10):** the section claimed "All release tags and Docker images are GPG signed" and told operators to run `git tag -s` and `cosign sign/verify ghcr.io/detain/phlix-server:v1.2.0`. Disk and registry evidence at `dd9e54a8`: `git verify-tag` returns `error: no signature found` for all five published tags (`v1.0.0`…`v1.2.3`); `grep -rni 'gpg|cosign|sigstore|attestation' .github/workflows/` finds zero signing steps and no job requests `id-token: write` (keyless signing is structurally impossible from these workflows); the GHCR referrers API for a live image digest returns `MANIFEST_UNKNOWN` — zero signature or attestation manifests exist; and the example's `v1.2.0` image tag has never been published at all (the registry serves zero semver tags, per the runbook's own tag table). The rewritten "Release Integrity (Signing: NOT SHIPPED)" section states the falsity plainly, inventories what IS true today (TLS + digest pinning, the per-run base-digest equality gate, immutable `<full-sha>-<variant>` tags, GitHub release assets — with what each is and is not worth), ships the three verification commands that actually work (each re-executed live before this commit), and lists the forward-only prerequisites for real signing (signed tags via operator GPG identity; keyless cosign via `id-token: write`; BYOK cosign) as the owner-gated decision they are. Docs only; no workflow or behavior changed. `RELEASE_PROCESS.md` +66/−19 lines. - **The Coding-Standards job is green again after 15 consecutive red runs (M-SERVER-CSFIX, executing the M-SERVER-09 r2 handoff):** the break-in was commit `74825f1e`'s lock churn, which moved phpstan 2.2.5→2.3.1 and illuminate/collections past v13.32.0 in one sweep. Two independent gates died: PHPStan's production corpus reported 8 errors (a now-unmatched `@phpstan-ignore` in `WebSocketServer.php:223`; twin `notIdentical.alwaysTrue`+`deadCode.unreachable` pairs on both `TraktApi.php` retry tails; a provably-dead `!== ''` ETag guard in `ArtworkByteResponder.php`; a `list<array<mixed,mixed>>` return in `SimilarityService.php`; an `constructor.unusedParameter` on `LocalNfoProvider`), and Psalm 6.5.0 CRASHED (`TypeParseTreeException: Unexpected space`) deep-scanning `Collection::pull()`'s `@phpstan-this-out ($key is null ? static<TKey|int, TValue> : $this)` — conditional types with `$this` in a branch are beyond psalm 6.5.0's type parser and no newer psalm resolves under `config.platform.php=8.3.0` (6.5.1+ need ≥8.3.16, an owner-gated floor change). Cures: the stale phpstan-ignore removed with its docblock rewritten to the measured truth; Trakt tails restructured to a psalm-satisfying `instanceof` guard whose redundancy phpstan's own fold names via one inline `@phpstan-ignore instanceof.alwaysTrue` per tail (runtime-loud; a throw can't silently mask a future control-flow edit) — the dead empty-page `return`s deleted; the 304-path ETag guard folded (non-empty proven on both entry paths, `Last-Modified`'s kept — mtime-0 is real); `SimilarityService::fetchItemsWithCompleteMetadata()` now returns through `RowMap::listFromMixed()`, the estate boundary parser; `LocalNfoProvider`'s intentional-unused ctor param gets its named inline ignore. The crash is cured at the dependency layer: a root `conflict` on `illuminate/collections >=13.32.0` (first offending release; v13.39.0 still offends, so there is no upward cure) pins the lock back to v13.31.0 — a surgical 1-package lock move — and psalm now analyses the full corpus instead of dying. Side effects of the streak also closed: with production green, the `phpstan-tests.neon` CI step (skipped, not absent, for all 15 runs) exposes phpstan 2.3.1's 7 new tests-corpus `alreadyNarrowedType` reports — each is a live repeat-call contract pin (memoization, single-shot, idempotence) and is kept with an inline identifier-named ignore per the S186 doctrine — plus 4 test files merged mid-streak carried 8 genuine Psalm findings (`MysqlOnlyIndexDialectGateTest` nullable-`db` closures, `ItemRepositoryPagingLawsTest` by-ref property alias + 5 count()-derived negative offsets, `JoinPasswordThrottleTest` tail offset, `ApplicationIndexBucketsFailClosedTest` over-promoted mock docblock), all cured at source. The first CI attestation run isolated two residuals with precision the swoole-less docker venue could not: the `assertGreaterThan` narrowing in `ItemRepositoryPagingLawsTest` does not propagate to psalm (replaced by the `fail()`-guard form that stayed green in the same run), and `mockery/mockery` 1.6.12→1.6.15 (same lock churn) lost the template return psalm read off `Mockery::mock()`, surfacing `PluginAutoUpdateBootCatchupTest:111` — pinned with a truthful `PluginLoader&MockInterface` var type. Evidence table: `.reviews/fixlog/M-SERVER-CSFIX__fix.md`. - **The `Release` workflow now asserts the repo's own version law instead of contradicting it, curing the seven consecutive red tagged runs (M-SERVER-09, review server__devops M1):** every `Release` workflow run since the `v*.*.*` trigger existed had failed — v1.2.0, v1.2.1, v1.2.2 and v1.2.3 across both July attempt rounds (seven failure runs on origin) — at the step `Assert Chart.yaml appVersion matches tag`, which looped over BOTH Helm charts and demanded `appVersion == tag`. But `scripts/release.sh` only ever writes the server-side mirror trio (`src/Common/Version.php::STRING`, the `VERSION` marker, `k8s/helm/phlix/Chart.yaml`), and `k8s/helm/phlix-hub/Chart.yaml` lives in the hub's version space — this runbook's own "Server-Hub Compatibility" section states the products "share no version space" — so the hub chart sitting at `appVersion: "1.0.0"` made the assert unsatisfiable by construction; its stale `# keep majors in sync` comment was fiction no tooling honored. The sibling assert (`composer .json`'s `version` == tag) contradicted the documented `f5375f7a` law that `composer.json` carries NO version field at all — it could only "pass" on a tree that failed `composer validate --strict`, which is what the v1.2.3 tag tree was, while the drift it never checked (`Version.php::STRING == 1.2.2` at the v1.2.3 tag) sailed through. Cure, both directions: the verify job (renamed `verify-version-sources` / "Verify version sources match tag") now strips any prerelease suffix, validates the MAJOR.MINOR.PATCH base, asserts exactly the mirror trio plus the composer no-version absence, and packages — without version-asserting — the hub chart; dead references (`coverage-report .tar.gz` asset glob no producer exists for, and the unread job-level `outputs.version`) were removed. `RELEASE_PROCESS.md`'s "GitHub Actions will:" item now states what the workflow actually does, including the falsified-claim narrative and the disclosure that a green end-to-end `Release` run still requires a real tag push (owner S274 release authority — the step logic was verified by dry-run replay against the v1.2.x trees, not by a published run). New `tests/Unit/Scripts/ReleaseWorkflowTest.php` (6 tests, 25 assertions; red 6/6 on the pre-fix files, green after) pins the shape and the doc↔workflow agreement; S431 census denominator re-pinned from the measured red, 1984 to 1985. - **`getByLibrary` and its paging siblings obey the class's own two paging laws again (M-SERVER-08, review server__dba2):** `ItemRepository` had already learned both laws — the trailing-`id` TOTAL-order tail stated in `getAllByType()`'s docblock (with ties on `sort_title, name` — the remake/original "Dune" case — MySQL arranges tied rows differently per `LIMIT + OFFSET`, so untailied page boundaries drop rows and repeat others) and the S99 `ABSOLUTE_ROW_CEILING` backstop (`min(max(1, $limit), ABSOLUTE_ROW_CEILING)`) — but most of the class never applied them. Measured from red on real MySQL 8.0 (400 identically-keyed rows, walked at 50): `getByLibrary()` repeated 40 rows per walk, `getByType()` 40, `getByAllowedRatings()` 41, `getByAllowedGenres()` 41 — and `SmartPlaylistEngine::iterateItemsForLibrary()`, which walks `getByLibrary()` at 500 per batch, was the silent victim (its evaluation skipped and double-counted members). Every caller-fed `LIMIT ?` also hydrated unclamped: a 10,001-row library came back 10,001 rows deep from `getByLibrary()`/`getTopLevelByLibrary()` in one resident Workerman worker. The cure copies the existing idiom exactly (no new abstraction): `, id ASC` tails on `getByLibrary`/`getByType`/`getByAllowedRatings`/ `getByAllowedGenres` and on both `query()` / `queryWithSmartRules()` select arms (the rating-join arm takes the QUALIFIED `media_items.id` — a bare `id` is ambiguous against `metadata_ratings.id`, MySQL 1052); the ceiling + offset normalisation on every method that lacked them (`getByLibrary`, `getTopLevelByLibrary`, the two allow-list siblings, `search`/`searchFuzzy`/ `searchMusicContainers`, `getRecentlyAdded` — the search trio and recently-added are top-N without OFFSET, so only the ceiling law engages). The clamp is inert for every legitimate caller: the estate's largest requested page is the 10,000-row slideshow, exactly at the ceiling (lowering to `PageLimit::MAX` would silently truncate it — the docblock law says so and still does). No route, wire code, or response shape moved. Proofs: `ItemRepositoryPagingLawsRealDbTest` (5 real-MySQL cases — four 400-tie walks + the 10,001-row ceiling — all 5 measured red at the parent, 40/40/41/41 repeats and a 10,001-deep fetch, green after) and `ItemRepositoryPagingLawsTest` (12 bind/SQL-text pins — 11 red at the parent, the 12th is the deliberate ceiling-is-10000 tripwire, which must stay green on both legs); the JOIN-arm tail and the `query()` arms are text-pinned because `query()`'s own `normalizeLimit` clamps to ≤100 and the shared venue cannot exercise them behaviourally. Census: `EXPECTED_ADOPTERS` 72→73 and `EXPECTED_PHP_FILES` 1982→1984, both re-read from the phpunit red on this tree, not predicted. - **Re-adding a collection item no longer raises MySQL 1062 → HTTP 500 (M-SERVER-07, review server__dba2):** `collection_items` is keyed by `PRIMARY KEY (collection_id, media_item_id)`, yet `CollectionItemRepository::insert()` issued a plain INSERT and `CollectionManager::addItem()` pre-checked nothing — adding the same item twice (double-click, retry after a network blip, two tabs) deterministically raised `PDOException ... Duplicate entry ... for key 'collection_items.PRIMARY'`, 500-ing POST /api/v1/collections/{id}/items/{mediaItemId}. `batchInsert()` was worse: one duplicate tuple failed the ENTIRE multi-row statement, losing even the fresh items of a bulk add. The cure is the estate's house duplicate-key idiom — the one `WatchHistory::record()` narrates and `PlaybackController::reportProgress()` ships — `INSERT ... ON DUPLICATE KEY UPDATE` with a self-assignment no-op survivor clause (`media_item_id = VALUES(media_item_id)`): a duplicate add is now an idempotent no-op that leaves the existing row's `sort_order` and `added_at` completely untouched (re-adding never re-orders a curated collection and never refreshes membership age). The HTTP surface is unchanged — a duplicate returns the byte-identical 200 `{message}` success envelope, no new wire codes — because the controller simply stops ever seeing a throw. Nothing is swallowed: the ODKU arm answers only the duplicate case, so non-duplicate SQL failures still fail loud (1406 probe pinned), the `bulkAddFromSearch()` pre-check is demoted to an optimization over the now atomic guard, and `applyMemberDiff()`'s lost-race case became a no-op instead of a rolled-back refresh. Proofs: 7-case `CollectionItemReAddRealDbTest` on a throwaway `phlix_s7_*` scratch DB built from the real 005 file (measured from red at the parent: 5 uncaught 1062s; green at tip, incl. the fail-loud and PK-shape precondition pins) and 3 unit SQL-shape/bind pins in `CollectionItemRepositoryTest`. - **MariaDB support claim and the dialect-gated migrations (M-SERVER-06, review server__dba):** the README advertised "MySQL 8.0+ or MariaDB 10.6+" while the chain carried four MySQL-only index statements — three functional key parts in `011_music_library.sql` (`CREATE INDEX ... ((CAST(metadata_json->>'$.x' AS CHAR(255))))`, MySQL 8.0.13+) and the multi-valued index in `050_media_items_sort_indexes.sql` (`CAST(... AS CHAR(255) ARRAY)`, MySQL 8.0.17+). MariaDB rejects both shapes with a hard 1064 that is not in the runner's idempotent-squelch list, so a fresh MariaDB install could never record 011/050 in `schema_migrations` and `run-migrations.php` exited 1 on every boot (measured against mariadb:10.6.28: 4 errors, EXIT=1). The shipped statements could not be edited in place — a checksum flip would re-apply the whole file and resurrect the 050 multi-valued index that migration 051 exists to purge — so the gate landed as comment-only directives: `-- @phlix-require-mysql <version> [reason]` lines, invisible to `MigrationRunner::checksum()` and evaluated by the runner when each guarded statement is about to run. A guarded statement is skipped (with an operator-visible note) on MariaDB-branded servers and on genuine MySQL below the stated minimum; an unparseable version or a failed probe still RUNS the statement with a logger warning — the historical behavior wins. All four statements are performance-only: 050's skip is lossless because 051 supersedes it with the `media_item_genres` join table MariaDB builds natively, and 011's three indexes only leave artist/album/genre metadata scans slower on MariaDB — no query or feature is lost. The README support block now says all of this, so the two sides agree. Proofs: fresh full-chain applies green on both servers (MariaDB 10.6.28: EXIT=0, exactly the 4 skip notes, 122/122 files recorded; MySQL 8.0.43: EXIT=0, zero skips, 011 indexes present, 051 purge intact), plus a 15-case `MigrationRunnerTest` dialect matrix and the real-MySQL `MysqlOnlyIndexDialectGateTest` (fresh-apply, end-state index inspection, zero-execution steady-state re-run). - **README LiveTV section C29 rework (round 2) — three over-claims corrected against the code and the served bundle (review C29):** round 1 stated "the web SPA has no LiveTV page", but the vendored `/app` bundle registers an admin-only LiveTV page (route `admin-livetv` inside the `requiresAdmin` admin shell, `LiveTvPage` chunk driving the full `/api/v1/admin/livetv` API — tuners, scan, channels, guide + refresh/programs, recordings + upcoming/series, series-rules); the README now says that — admin-only page, no member-facing page. The "all driven through the admin API" framing over-attributed two bullets: recording storage tracking/limits have zero admin routes (`AdminLiveTvController` carries no storage handler, `Recorder::getStorageStats()` and siblings have no callers outside `Recorder.php`, enforcement is internal to scheduling via `hasStorageSpace()` and configuration is `config/livetv.php` `storage_path`/`max_storage_bytes`), and EPG "search" is not an HTTP parameter (`searchPrograms()` is invoked only internally with an empty query; `/guide` accepts `channel_id`/`from`/`to`) — both bullets reworded and the framing sentence qualified. The multi-tuner bullet certified DVB-S/DVB-C/ATSC as shipped types while only DVB-T, HDHomeRun, and IPTV drivers exist (the other three are dead `LiveTvManager` constants, referenced nowhere outside their declarations, and `updateTuner` only edits name/enabled); it now names the real drivers and marks the rest reserved. Docs only; no behavior change. - **Release runbook C05 rework (round 2) — three rollback-section claims corrected against the evidence (review C05):** the replacement section's "stray words passed to either entry point are silently ignored" held only for `scripts/run-migrations.php` — `bin/phlix` runs on symfony/console (pinned v7.4.21), where a stray extra word after `migrate` exits non-zero with "No arguments expected" and an unknown `migrate:down` is refused as an undefined command; the runbook now says exactly that instead of implying a silent forward run. The blanket "recent migrations are additive or drop dead indexes" was falsified by the newest member of its own chain — `migrations/120_collation_unicode_ci_conversion.sql` is a ten-table collation `CONVERT TO CHARACTER SET` rebuild — so the sentence now enumerates rebuilds and points at their in-file header analyses. And the restore step listed the archive's `data/` tree and SSL certs as if they came back with a restore; `BackupManager::restore()` imports the database dump and config files only, which the runbook now states explicitly (archived `data/` and SSL content needs manual extraction). Docs only; no behavior change. - **Release runbook: the documented database rollback was fictitious — replaced with the real recovery path (review C05):** `RELEASE_PROCESS.md` "Database Migration Rollback" told operators to run `php scripts/run-migrations.php rollback` and `… migrate:down <version>`. Neither exists — the script parses no arguments at all, so an incident-time "rollback" silently ran the FORWARD migration, the opposite of what was asked. The section now states that no automated rollback exists and documents the verified mechanisms instead: ledger-skipped image rollback, restore via `bin/phlix backup:create`/`backup:list` + the admin-API restore endpoint (checksum-verified, with its honest semantics — post-backup migrations replay forward, the `backups` table itself rolls back), forward-remediation as the supported heal, and the per-migration `## Reversibility` recipes as manual, human-audited escape hatches. Every command named in the replacement was re-verified against the registered `bin/phlix` commands and the router. Docs only; no behavior change. - **Review-fix lane (2026-10-15) — installer redirect defense gets real e2e teeth + temp-file leak on the reject path:** `HttpInstaller::downloadToTemp` carried two layered defenses against being redirected into a different payload — the HTTP stream context pins `max_redirects => 1`, and `assertNoRedirectResponses` rejects any 3xx status line in the response chain — but the only test exercised the checker by reflection, so deleting EITHER layer survived the suite (adversarial review MINOR). New `HttpInstallerRedirectE2eTest` (4 tests) drives the genuine code path against a real loopback `php -S` fixture server: a 302 hop must raise `PluginInstallException` and the hits log proves the `/target` was never fetched (kills checker removal AND context-option removal independently — mutation-proven 2 failures each); a three-hop chain stops at the first hop; plain 200 downloads land byte-correct; a 404 still fails with the original fetch error, not the redirect message. The e2e run also surfaced a real leak the reflection test could never see: when the redirect checker threw, `downloadToTemp` returned past its `tempnam` without unlinking it, which reddened the S439 zero-residue census via the new tests — the throw path now unlinks before propagating, exception byte-identical. - **The deny-all fallback pin no longer grades itself against its own source — it asserts the literal fail-closed shape (Review-fix lane, 2026-10-15):** `ApplicationIndexBucketsFailClosedTest` verified the broken-gate fallback by comparing captured params against `RatingGate::denyAll()`'s own output, so a future flip of `allowUnrated` INSIDE `denyAll()` would have kept the test green while the index silently became allow-all. The pin now asserts the literal fail-closed shape (`['__phlix_no_user__']`, `allowUnrated => false`) and keeps a separate SSOT-linkage assertion against `denyAll()` (mutation-proven: flipping `denyAll` internally reddens exactly this test). - **SRV-SEC remediation (2026-10-14 review family) — R20-01/R20-02/R20-03/R20-05 outbound-HTTP and exposure fixes + R5-03 webhook vocabulary unification:** five findings from the review family, all re-verified on disk before the fix. **R20-01 (MEDIUM, SSRF):** `WebhookHttpClient::postAsync()` handed `workerman/http-client` options without an `allow_redirects` policy, so the vendor default (max 5) followed redirects with NO per-hop re-check of the `SsrfGuard` that gated the original URL — the FINAL redirected body was persisted to the admin-readable delivery log (exfiltration primitive) and the 301/302/303 re-issue downgrades POST to GET (internal scanner). The async options now carry the explicit deny `['max' => 0]`, so a 3xx lands as a failed delivery; the cURL fallback never set `FOLLOWLOCATION` and was already safe. Pinned through the real coroutine fork (`max=0` shape + `Location: 169.254.169.254` proves one request, no second hop). **R20-02 (LOW-MED, same root class):** `HttpInstaller::downloadToTemp()` used bare `file_get_contents()`, whose HTTP wrapper follows up to 20 redirects past the entry-time guard — a blind internal port-probe. The fetch now runs with `max_redirects=1` + `ignore_errors=true`, and ANY 3xx status line anywhere in `$http_response_header` is a hard install failure ("HTTP redirects are not allowed"); surfaced 4xx/5xx keep the pre-existing "Failed to fetch" arm. Local `file://` installs are byte-identical (pinned). **R20-03 (LOW):** `HttpHandler::serveStatic()` allowed all-except-`.php`, serving Vite source maps (full SPA TypeScript via `sourcesContent`) and `public/templates/emails/newsletter.tpl` sources. The single php deny became `DENIED_STATIC_EXTENSIONS = ['php','map','tpl']` (case-insensitive on the jailed realpath; fall-through semantics unchanged; `sourcemap: true` deliberately stays on for tooling). The hub's static twin is deliberately NOT touched here — the hub lane owns it. **R20-05 (LOW):** `AvatarStorage::ensureStorageDirExists()` embedded the absolute filesystem path in its RuntimeException and BOTH controllers (`UserAvatarController`, `ProfilesController`) echoed it verbatim as a 500 body. The throw is now path-free, the clients get the house generic copy ("Storage error, please try again later."), the exception is routed to the HTTP channel log (`context.exception`), and the raw `mkdir()` warning is suppressed at the chokepoint so the attempted path cannot reach any stderr/error-capture sink. **R5-03 (MAJOR, feature-dead):** webhook event vocabulary had drifted into three disjoint lists — `WebhookEventSubscriber` emitted `media.*`/`user.*`, the admin SPA picker and the `phlix-shared` webhook-events schema offered the playback/library vocabulary, and the `webhook_subscriptions` REST gate validated a disconnected fiction (`media.scanned`/`transcode.*`/`user.watched`/`syncplay.*`) that nothing ever emitted — so `handlesEvent()`'s strict match never landed a real domain event on a UI-created subscription, and the /test button's `webhook.test` success was a decoy. New `Phlix\Webhooks\WebhookEventCatalog` is the single vocabulary SSOT (10 events: the schema's 7 wire-truth entries plus `user.login`, `user.logout`, `user.created`, which the vendored schema does not yet carry — widening the schema is the upstream `phlix-shared` lane's decision); the emitter now speaks it (`media.started` → `playback.started`, `media.played`/`media.stopped` → `playback.ended` with the `reached_end` payload bool preserved, `media.added` → `library.updated`, the `user.*` trio kept verbatim), `handlesEvent()` compares both sides through `normalize()`, and `AdminWebhooksController` validates + stores canonical (legacy stored rows keep delivering — migration-free aliasing). The picker gains the matching "User" group in `@phlix/ui`. **Structural observation (explicit non-fix):** `WebhookDispatcher::dispatch()` — the table behind the public `/api/v1/admin/webhooks` API — has no production caller at all; wiring that store into the live delivery path (or retiring it) is an architecture decision above this lane. New `WebhookEventVocabularyTest` drives the real subscriber → service → record chain end-to-end with a recording DB connection (no mocks that re-encode the bug): 8 tests / 36 assertions green; webhook family 139 tests / 1325 assertions. - **SRV-OPS remediation (2026-10-14 review family) — R14-14 job queues off /tmp:** the five file-backed job queues (marker, media-asset, collection, metadata-write, similarity) defaulted to `/tmp/phlix_<name>_jobs` in three duplicated places per queue (config literal, `MediaServicesProvider` factory fallback, store constructor default) — and `systemd/phlix-server.service` runs `PrivateTmp=true`, so every restart silently discarded the queued backlog while out-of-unit producers (cron'd backfills, manual `scripts/run-*-worker.php`) wrote to the REAL `/tmp` and the daemon never saw a job. New `Phlix\Config\JobQueuePaths` is the single resolver all fifteen sites now funnel through, with precedence `PHLIX_<NAME>_JOBS_DIR` > `PHLIX_JOB_QUEUE_ROOT` > `<repo>/var/jobs/<name>` (empty env = unset; path-unsafe queue names fail fast). The new default sits inside the unit's existing `ReadWritePaths=/var/www/phlix/var` grant and the Docker image's already-writable `var/`, so backlogs survive restarts and every producer/consumer shares one path. The five stores' `QUEUE_DIR` constants are gone (constructor default now resolves through the helper; explicit paths still win); `var/jobs/` is gitignored; the ~27 `S439` suite residue sweeps now follow the resolver instead of hardcoded `/tmp` names, and `ZeroResidueCensusTest`/`TempDirMintGuardTest` stay green because construction mints nothing under `/tmp` any more. New `JobQueuePathsTest` (11 tests: default placement, precedence ladder, empty-as-unset, slash normalization, fail-fast names, and a three-way config↔resolver↔store-default agreement pin per queue). - **SRV-OPS remediation (2026-10-14 review family) — R14-06 listen wiring:** `start.php` hardcoded `http://0.0.0.0:8096` (count 14) and `websocket://0.0.0.0:8097` while `config/server.php` shipped `server.host/port`, `websocket.host/port`, `worker.count`, `process.reloadable` and `process.reuse_port` that nothing binding a socket ever read — a config illusion, worsened by the DLNA advertisement reading `server.port` while the real bind ignored it. NEW `src/Server/Runtime/ListenEndpoints.php` resolves both endpoints with precedence **env > config > shipped default** (env seams `PHLIX_HTTP_HOST` / `PHLIX_HTTP_PORT` / `PHLIX_HTTP_WORKER_COUNT` / `PHLIX_WS_HOST` / `PHLIX_WS_PORT`; empty string = unset), and `start.php` writes the resolved host/port back into `$config` before the fork so the DLNA advertised port and the `WebSocketServer` status metadata describe the socket that actually accepts. Defaults are byte-identical to the old hardcodes (`0.0.0.0:8096`/14, `0.0.0.0:8097`); the WS worker's count stays 1 by the single-authoritative-`SyncPlayManager` law. Hostile values (non-numeric or out-of-range port, bare IPv6, non-positive count) throw at boot naming the key instead of binding somewhere surprising. `process.reloadable`/`reuse_port` now reach `Worker::$reloadable`/`$reusePort` on both listening workers; `reuse_port` ships `false` — its old inert `true` must not silently flip deploys onto `SO_REUSEPORT` (opt-in now, law pinned by test). `worker.count => 'auto'` resolves to the shipped pool of 14 (documented); `worker.stdout_file` stays deliberately unwired (journald/container stdout capture — annotated at the key). Pins: 13-test `ListenEndpointsTest` (defaults byte-identity, ladder, fail-loud roster, opt-in law) and the S431 census re-pinned 1987→1989 from its measured red. - **SRV-HTTP remediation (2026-10-14 review family): WebSocket reaper vacate, WS token-type gate, join-password throttle, list-row path de-leak, fail-closed rating degrade, require-TLS relay knob, and user-facing copy.** **R16-02 (HIGH):** the `WebSocketServer` ping-sweep reaper closed dead connections and removed the pool entry *before* the deferred `onClose` ran (Workerman defers destroy/onClose whenever the sendBuffer is non-empty, and pool-removal makes the later `onClose` resolve a null connection) — so the SyncPlay vacate never ran and ghost members/hosts persisted in active rooms. The reaper arm now calls `finalizeConnectionTeardown()` (SyncPlay `onConnectionClose` vacate + the `openedConnectionIds`-gated final metrics touch) *before* close/remove; the normal `onClose` path uses the same routine, and double-teardown is idempotent by the `connectionToMember` mapping gate (proved by re-entrant tests). **R17-02 (MEDIUM):** `SyncPlayAuthMiddleware` validated WS tokens with the type-blind `JwtHandler::validateToken()`, so 7-day **refresh** tokens authenticated the socket. All three validation sites now require `type === 'access'`; the middleware keeps only the HS256 secret at the :8097 fork, so the account-liveness probe is documented as the honest follow-up (≤1h access-TTL exposure window). **R17-03 (LOW):** the `MessageHandler` catch-all echoed `'Handler error: ' . $e->getMessage()` onto the wire; it now sends a fixed generic string and logs the exception detail server-side (websocket channel). **R17-04 (LOW):** group-join passwords had no wrong-guess throttle; NEW `SyncPlay\JoinPasswordThrottle` keeps a per-connection 60 s sliding window (ceiling 10 failures, ctor-guarded) and on trip sends `syncplay.join_failed` + closes the connection; success resets, disconnect clears. Process-local by design (:8097 runs count=1). **R17-05 (LOW):** the never-bound, handshake-auth-less standalone `Workers/SyncPlayWorker.php` bootstrap (:8098 legacy) is deleted — zero live references; `WebSocketServer` owns the served :8097 path. **R12-04 (MEDIUM):** `MediaItemShaper::shape()` leaked the FULL filesystem path of every media file to every authenticated user on list rows. List rows and non-admin detail rows now carry `basename($path)` under the same always-present `path` key (string|null; type-stable for clients that read it for the extension — nobody needs the directory), while `shapeDetail()` keeps the full column value for admins only. **R18-01 (MEDIUM):** the `GET /api/v1/media/index` rating-gate resolution `catch` degraded FAIL-OPEN (no cap params) while its docblock promised deny-all; a broken gate now degrades to `RatingGate::denyAll()` (unrated-hidden, no rating admits nothing) — the A–Z bucket route can no longer widen access when the gate itself is down. **R17-01 (policy knob):** NEW `PHLIX_RELAY_REQUIRE_TLS=1` / config `relay.require_tls` — with it on, `RelayConsumer::connect()` REFUSES a non-`wss://` hub dial before any socket opens (health-panel reason recorded, normal backoff re-arms) instead of only emitting the M5 warning; `require_tls` wins over `allow_plaintext_tunnel` — an ACK cannot re-open a refused dial. Default off keeps today's warn-only behavior byte-for-byte (docs lanes will surface the knob). **Copy (R17 pass):** OidcCallback's class-name leak (`is not an OidcProvider instance`), AdminRestart's `pid_file` config-key leaks, AccountLink's lowercase/run-on missing-credentials sentence, and OidcAdmin's `redirect_uri` key leak in user-facing copy are reworded; the stable `error` keys are untouched, and no message strings were test-pinned. - **SRV-DB remediation (DBA review R19 family): races, clock drift, missing indexes, unbounded stats growth, collation drift and an N+1 similarity read, shipped as migrations 113–120 with static + real-MySQL guards.** **R19-02 (HIGH):** `WatchHistory::updateProgress()` was a check-then-act (pre-read, then UPDATE *or* INSERT keyed on it) — two concurrent progress reports for one (profile, item) could both miss the row and race to INSERT, surfacing MySQL 1062 on the `idx_profile_media` UNIQUE mid-playback. Now ONE `INSERT ... ON DUPLICATE KEY UPDATE` on that key (the `PlaybackController::reportProgress()` estate idiom); `completed_at` semantics preserved verbatim via COALESCE'd constant expressions (the threshold-cross arm writes NOW(), the other NULL keeps the old stamp), and the pre-read survives only as the completion classifier for newly-completed recommendation triggers. **R19-03 (HIGH):** `SessionManager` wrote `last_activity` with the DB clock (`NOW()`) but compared it against PHP-`date()` cutoffs in `cleanupStaleSessions()`/ `getOnlineUsers()` — host-vs-MySQL timezone skew (proven live on this venue: PHP EDT vs UTC server) shifted every cutoff, the nightly mass-logout shape. Both cutoffs are now `DATE_SUB(NOW(), INTERVAL ? SECOND)` (DB clock on both sides), backed by NEW migration **113** (`sessions(last_activity)` — the sweeps were unindexed full scans). `DbRemediationRealDbTest` pins it under a UTC+14-shifted PHP clock: an 8h-idle session survives a 24h DB-clock cutoff even while PHP believes it is 14h later. **R19-05 (MEDIUM):** `getContinueWatching()` materialized every qualifying `playback_state` row (wide `ps.*` + three-way media JOIN per row) before ranking; rewritten to rank a narrow per-item projection first, `rn = 1`, LIMIT, then JOIN media metadata only for the ≤limit survivors — same wire output (mapper keys were already the sole consumers; the dropped `ranked.id`/`session_id`/ `playback_status` were never read), pinned byte-compatible by the existing unit regexes plus a real-DB dedupe/newest-wins behavior proof. NEW migration **114** adds `playback_state(media_item_id, updated_at)` for the window ordering. **R19-06 (MEDIUM):** NEW migration **115** adds `watch_history(profile_id, last_watched_at)` + `(profile_id, completed_at)` (every list read filters one profile and ORDER BYs a time column — pure filesort before); `getTodayWatchTime()`'s `DATE(last_watched_at) = CURDATE()` became the equivalent half-open range (`>= CURDATE() AND < CURDATE() + 1 DAY`) so the column stays index-usable, boundaries proven against the database clock. **R19-07 (MEDIUM):** NEW migration **116** adds `active_streams(session_id)` — the heartbeat UPDATE, teardown DELETE and exists-check all filter session_id alone, which `idx_profile_session` cannot serve (leftmost-prefix rule). **R19-10 (MEDIUM):** the two stats tables shipped (019) with a PRIMARY KEY only and no expiry story at all: NEW migration **117** adds the four indexes the dashboard/newsletter/sweeper reads need (`stats_library_changes(changed_at)`; `stats_user_activity(occurred_at)`, `(activity_type, occurred_at)`, `(user_id)`), and `StatsCollector` gained a bounded retention sweeper — hub-`ServerReaper::sweepHeartbeats` two-phase idiom (harvest ids via the `DATE_SUB(NOW(), INTERVAL ? DAY)` cutoff `ORDER BY id LIMIT 1000`, DELETE by PK `IN (...)`, deadlock retry ×3), batch loop capped at 20 rounds/table, `config/stats.php` `retention_days` knob (default 90; junk clamps fail-safe), wired into the existing `startBackgroundTimers()` fleet as an hourly `Workerman\Timer` tick (no boot-immediate run; `isEnabled()` gates each tick). `stats_playback_events` is deliberately NOT swept (playback analytics retention is a separate decision). The real-DB venue caught two shipping bugs in this feature during development: the harvest ids are CHAR(36) UUIDs (numeric-only acceptance would have swept nothing), and both fixes are pinned unit-side (13 tests) and end-to-end (100-day row deleted under a configured 30-day window, 10-day row survives). **R19-14 (LOW):** NEW migration **118** adds `media_items(library_id, created_at)` for the recently-added rail (was a filesort over every row of the library); the year-sort functional index is NOT shipped — the sort source is JSON `metadata_json` release-year on a huge table, so a functional index there is a full rebuild with uncertain optimizer uptake (recommendation only). **R19-15 (LOW):** NEW migration **119** drops the three provably-dead indexes — `users.idx_username`/`users.idx_email` (inline UNIQUEs on the same columns already cover every lookup, uniqueness-aware) and `item_similar.idx_score` (both readers are per-seed via the PK prefix); a repo-wide grep proves nothing ever `USE`/`FORCE`s them. `relay_user_quotas` is hub-owned and skipped here. **R19-09 (MEDIUM):** ten tables created by migrations 012/057/061/062/063/075/076 said `DEFAULT CHARSET=utf8mb4` but no COLLATE, silently taking MySQL 8's `utf8mb4_0900_ai_ci` against the estate's `utf8mb4_unicode_ci` (illegal-mix / unindexable-coercion risk on cross-table comparisons). NEW migration **120** CONVERTs exactly those ten small FK-free tables (cost stated in-file: a rebuild, seconds at these row counts; media-scale tables were already compliant). The policy holds going forward via a new CI assertion in `DbRemediationMigrationsTest`: every migration numbered ≥113 that CREATEs a table must pin `utf8mb4_unicode_ci` in the statement itself. **R19-12 (LOW-MEDIUM):** `RecommendationService::computeBecauseYouWatched()` fanned out one `SimilarityService::getSimilar()` round trip per seed (up to 50 inline); the `SimilarityService` dependency is now gone entirely (constructor and DI param in `MediaServicesProvider` shed it) and the read is ONE query — per-seed top-20 via `ROW_NUMBER() OVER (PARTITION BY media_item_id ORDER BY score DESC)` in a DERIVED TABLE with the `media_items` INNER JOIN inside the window (dangling targets drop before ranking, exactly like the old post-JOIN LIMIT), so scores, exclusion, ordering and stored top-K are unchanged. The derived-table form is load-bearing, not style: the vendored Workerman driver picks its return shape from the FIRST SQL keyword, so a `WITH`-leading CTE executes but hands back null — caught only because the real-MySQL venue executes the production driver. **R14-07/R19-16 (LOW):** `ConnectionPool`'s three `[DEBUG]` `error_log` lines removed (they spammed stderr per checkout under the SHIPPING pool default — the stale "OFF unless pool_enabled is set / validate before trusting under load" comment contradicted `config/database.php`, which has been pool-ON by default since Track S/S9); pool-math guidance added to both the class and `config/database.php` (14 HTTP workers × pool_size 8 ≈ 112 plus the count=1 WS/background pools ≈ 140 at defaults — keep under `max_connections`, tune pool_size first); `PooledMySQLConnection`'s header now names PhlixMySQLConnection as the opt-out front, not "the default". **R19-17 (LOW): DEFERRED** — folding `lookupPlaybackStatus()` into the upsert round is not trivially safe: MySQL ODKU cannot return the PRE-update status the event dispatch needs, and the LAST_INSERT_ID trickery would trade one indexed point-read for fragile session state. - **`updatePlaybackPreferences()` now maps a rejected `updateSettings()` to the same flat `{error}` 400 envelope as `PUT /api/v1/users/me/settings` (defense-in-depth, 2026-10-14).** The read-modify-write handler had no `InvalidArgumentException` catch: it is safe today only because its `unset($settings['theme'])` guard strips the only field that validates, but any validation a future lane adds to `UserRepository::updateSettings()` would otherwise turn stored garbage into an uncaught 500 on every unrelated crossfade save. The catch is folded in at the call site (the repo idiom the settings route already uses; no new error codes). Pinned by a new `WebPortalRouterSettingsTest` case injecting the throw at the repository seam; the pre-existing legacy-RMW test keeps pinning the guard itself. - **`PUT /api/v1/users/me/settings` theme writes now validate against LIVE theme truth — the pre-S85 fossil whitelist that silently dropped EVERY theme while answering 200 is retired, and `UserRepository` consults the container `ThemeSourceRegistry` singleton (2026-10-14).** The theme branch inside `updateSettings()` accepted only `'phlix-light'/'phlix-dark'/'phlix-amoled'/ 'phlix-contrast'` — the id namespace from before S85's registry theming — while live ids are `BuiltInThemes::IDS` (`nocturne`/`daylight`/`midnight`) ∪ the plugin ids enabled into `ThemeSourceRegistry`. The intersection is EMPTY, so no theme PUT ever reached `user_profiles.active_theme_id` yet the endpoint kept returning 200 "Settings updated" (no test covered the branch, so CI never saw it). Now: a submitted `theme` is parsed BEFORE any write (a rejected payload changes zero rows) and must be a string naming a built-in or a registry- registered theme; unknown/non-string values throw `InvalidArgumentException`, which the router maps to the endpoint's existing flat `{error}` 400 envelope — no new error codes (contract law). Wiring: `UserRepository::__construct` gains `?ThemeSourceRegistry $themeSources = null`; because PHP-DI skips optional ctor params during autowiring, `AuthServicesProvider` names it explicitly — `constructorParameter('themeSources', get(ThemeSourceRegistry::class))` — keeping the singleton law (the same instance `PluginLoader` enables into and `ThemesController` lists from). The unwired fallback accepts built-ins only. Read-modify-write guard: `updatePlaybackPreferences()` re-passes the `getSettings()` read-back — which injects the STORED theme, possibly a legacy id — so it now drops `theme` before the re-pass: a legacy stored id can never 400 an unrelated crossfade save, and that endpoint never touches user_profiles (pinned). INTENTIONAL behavior changes: fossil ids, unknown ids, and explicit `"theme": null`/non-string values now 400 where they were previously ignored with a 200 — a 200 from this endpoint no longer lies about a theme write. Tests: `UserRepositorySettingsTest` +9 (built-in persists; plugin theme persists through a real registry fixture; upsert→theme statement-order pin; fossil/unknown/non-string throw with zero-write proofs; unwired-registry plugin rejection; error message lists the built-in∪plugin id set) and `WebPortalRouterSettingsTest` +3 (repo rejection → 400 echoing the message; valid theme → 200; legacy-stored-theme crossfade save → 200 with zero `UPDATE user_profiles`). Census rotations: `RequestDynamicPropertyCensus- ExecutableTest` EXPECTED_DECLARED_READS 448→451 and EXPECTED_DECLARED_WRITES 1042→1051 — every delta is an inline `$request->userId`/`->body`/`->rawBody` stamp in the three new router fixtures; measured from the phpunit red, not predicted. - **Thumbnails, trickplay sprites and BIF frames no longer hard-abort on ffmpeg ≥ 7.1 — every mjpeg/image2 output `FfmpegRunner` builds now carries `-strict unofficial` (the CI BtbN n6.1.3 pin retires with it).** User-visible impact: on any deployment running a modern distro ffmpeg (7.1+; Ubuntu 26.04 ships 8.x), chapter thumbnails, poster extraction, trickplay sprite sheets and Roku BIF frame passes died with exit 234 — "Non full-range YUV is non-standard" — whenever an output flushed EMPTY, i.e. whenever a requested `-ss` timestamp landed past EOF (short/variable-length media, a chapter marker beyond a remux's duration). Worse, in the S-F19 batch path one unreachable timestamp poisoned the whole command: reachable frames that were already being written were discarded and the batch reported failure. Root cause: JPEG is full-range YUV; from 7.1 the mjpeg encoder treats a limited-range (yuv420p) frame as a fatal compliance violation instead of the 6.x/7.0-era warning, and an EMPTY output opens its encoder at the EOF flush with exactly that default. `-strict unofficial` restores the pre-7.1 semantics the S-F19 partial-success contract documents (exit 0, reachable frames written, unreachable ones simply absent). Scope law: the flag is attached ONLY to the four mjpeg/image2 output sites — single thumbnail (`buildThumbnailCommand`, extracted for testability), thumbnail batch (per output group), trickplay sprite (`buildTrickplaySpriteCommand`) and BIF frames (`buildBifFramesCommand`) — never to HLS/segment/audio commands, so the compliance relaxation cannot mask conformance problems in streaming encodes; `FfmpegRunnerSegmentFormatTest`'s byte-identical literal pins stay green UNMODIFIED as the structural proof. Tests: +5 argv-shape pins in `FfmpegRunnerThumbnailBatchTest` (flag count/adjacency/placement laws per mjpeg site + the negative half: segment and audio commands never contain `-strict`). Measured 2026-10-08 with real binaries through the actual PHP code path: pre-fix batch returns false with exit 234 on BtbN master (N-127252) and 7.1/8.x apt builds; post-fix exit 0 with frame_00000 written and frame_00001 absent, identical on 6.1.1/6.1.3 (flag inert there — no regression). CI: the ubuntu-26.04 migration's interim `Install ffmpeg (pinned BtbN n6.1.3 static)` workaround is retired in favour of the distro binary (the thumbnail suite now runs against the ffmpeg real deployments ship). No new wire codes; no migrations. - **`GET /livetv/recording/{id}/stream` now honours the parental rating cap (wave I close — the audit finding: the serve-time over-cap gate covered HLS/DASH jobs only; LiveTV streams bypassed it).** A completed DVR recording is registered as a `media_items` row by `RecordingMediaRegistrar` and linked back through `livetv_recordings.media_item_id` (migration 077), so the library rails already gate that content: `show`/`getPlaybackInfo`/`getDownload` refuse over-cap items (S235 handler-side deny-all) and the HLS/DASH byte paths re-check via `TranscodeFileServer::transcodeJobOverCap()`. The recording stream route served the IDENTICAL `.ts` bytes from the recording id alone with no gate anywhere on the path — and the recording uuid is not secret: the registered item's absolute `path` (`{storage}/{recordingId}.ts`) ships in the member-facing item payload (`MediaItemShaper::shape()['path']`), so a capped member can derive the bypass URL from any visible row, on top of the leaked/replayed signed-URL posture Finding 1b closed for HLS. `LiveTvStreamController::streamRecording()` now runs the exact same predicate: the recording's linked media id through `RatingGate::resolveFilterForSignedRequest()` (this route sits behind `SignedUrlMiddleware` like `/hls`, so the S235 signature-only opt-out carries over unchanged — bare signed fetches keep playing, pinned) and `isAllowed()`, refusing over-cap streams with a 404 whose body is byte-identical to the existing not-found answer (no existence oracle). An UNLINKED recording (in-progress capture, or a failed/zero-length completion the registrar never registered) has no library counterpart to key on and stays ungated — the trait's stale-job pass-through posture. `Recorder::mapRecording()` surfaces `media_item_id` (the `SELECT *` already carried it) so the gate reads it without a second query. Deliberate non-change, documented in the controller: the timeshift routes serve the live tuner's rolling window — session rows carry only `channel_id`, channels have no rating concept, and `livetv_programs.rating` is ingest/display-only with zero gate consumers estate-wide — so operator-configured lineups remain unrated-by-design; a profile-level channel restriction would be a NEW concept (owner decision), not gate parity. Red-first proofs (outsider over-cap fetch served 200 pre-fix → refused post-fix; admin/owner, within-cap, unlinked and anonymous-signed postures pinned green across the flip) in `tests/Unit/Server/Http/Controllers/LiveTvRecordingParentalGateTest.php`; the Application factory wiring is guarded by a whole-line source pin in the same file (fa30b871 law: a trailing-optional param no factory passes is a silent no-op). No new error codes, no route/middleware tuple moves; census tripwires re-pinned from measured reds (files 1941→1942, declared reads 446→448, writes 1032→1033). **Correction (2026-10-02, wave-I review) — the derivability claim above is overstated, forward-only note (the shipped commit message is not rewritten).** Capped surfaces filter over-cap rows by the active profile's cap BEFORE shaping (the `ItemRepository` content-rating allowlist plus the S235 handler-side denials), so a capped member is not handed the row whose `path` would leak the recording uuid; registered recordings are additionally unrated-at-birth — the registrar writes no `rating`/`official_rating` metadata, so `content_rating` is NULL and only a deny-unrated cap filters them at all. The real pre-fix vectors were signed-URL replay under the deliberate S235 signature-only opt-out, shared-account profile-switch boundaries, and uuids learned via UNCAPPED household surfaces (admin/uncapped payloads do ship `path`). The fix posture stands unchanged — the serve-time re-check answers every session-bearing request however the URL was learned; only the justification narrative is corrected. A container-resolution wiring guard (`tests/Unit/Server/Core/` `LiveTvStreamControllerWiringGuardTest.php`) now complements the whole-line source pin: it resolves the production provider stack and fails on the null gate that `optionalRatingGate()`'s catch would otherwise swallow silently. - **`PATCH /api/v1/media/{id}/metadata` now parses `metadata_json` against a closed allowlist at the HTTP boundary (scan residual F-08).** The handler merged the user-supplied `metadata_json` object straight into the stored provider blob unvalidated. Two exposures closed: (1) any authenticated user (the route is `AuthMiddleware`-only) could plant `rating`/`official_rating` in the blob, which `ItemRepository::update()` materializes into the `content_rating` COLUMN the parental gate reads — `{"rating":null}` nulled the column and leaked the item into kids profiles; plus `canonical_key` (dedup identity), `source`/`streams` (ABR-ladder input), poster/trailer/`theme_audio_url` (the last ships to clients un-sanitized at emit) and the matcher-identity `external_ids` family were all user-writable. (2) `hydrateItem()` keeps `metadata_json` as the raw JSON string and only the decoded array in `metadata`, so the old `is_array($item['metadata_json'])` merge base collapsed to `[]` in production and every PATCH REPLACED the stored blob, clobbering provider metadata — the merge base is now the decoded `metadata` slot. The patch is parsed, not validated: twelve reader-evidenced descriptive keys (`summary`, `overview`, `tagline`, `episode_title`, `artist`, `album` — strings; `year`, `runtime`, `season`, `episode` — non-negative ints; `genres`, `tags` — lists of ≤64 non-empty strings ≤255 bytes) are the only accepted top-level keys, each coerced to its declared shape; unknown keys are REFUSED with 400 naming them (zero legitimate HTTP senders of `metadata_json` exist in the estate — web-ui never PATCHes this route, phlix-ui's `updateMetadata` caller sends only `title`, and the native clients never call it — so fail-loud costs nothing and a future legit key arrives in the same change that reads it), as are non-object payloads, patches over 65536 serialized bytes (documented bound; the column itself is MySQL `JSON`), nested object/array-of-array bombs and scalar-shape mismatches. The allowlist guards the HTTP boundary ONLY — scanner, matcher and `MediaPosterController` keep writing the full provider vocabulary through `ItemRepository::update()`, which stays permissive (pinned). Proofs in `tests/Unit/Server/Http/Controllers/MediaItemControllerMetadataMergeTest.php`. - **`WebPortalRouter::isAdminUser()` now demands an ACTIVE admin (reviewer-flagged predicate alignment).** The L-4 paths-redaction gate (and the `files` disclosure on media detail) resolved the caller via `UserRepository::findById()` + `is_admin == 1` — a softer predicate than the repo-wide standard `is_admin = 1 AND status = 'active'` that `AdminMiddleware::checkAccess()`/`isAdmin()` and the daemon-twin `LibraryController` redaction enforce via `findAdminById()`. A disabled/pending/suspended admin inside the token-revocation latency window therefore satisfied the disclosure gate while failing the hard admin gate. On direct HTTP the window is ≤5 s (AuthManager re-checks account status per request through its 5 s auth-state cache, failing closed — `AuthManagerRevocationCeilingTest`), and the relay path was audited as the load-bearing consumer: the server trusts the hub-stamped relay principal and never re-reads its own users table per relayed request. `isAdminUser()` now delegates to the same shared seam (`findAdminById() !== null`), mirroring `AdminMiddleware` exactly (one indexed PK lookup, same uncached cost the soft admin gate already pays). No caller needed "admin regardless of status" — all three call sites are disclosure gates. Pins: `WebPortalRouterPathsRedactionTest` gains disabled/pending/suspended × both library handlers (red-first: each leaked `paths` pre-fix), and the fixture user-mock now mirrors `findAdminById`'s real SQL beside `findById` so both sides of the swap stay faithful. - **The SPA `/app/library/scan` route is admin-gated (L-4 follow-up, layer 2 of 2).** L-4 stopped shipping `paths` to non-admins on `/api/v1/libraries`, but the scan page still mounted for them: the route in `web-ui/src/main.ts` carried no gate, and the page's `v-if="library.paths.length"` turns a stripped payload into a render TypeError (`undefined.length`), not the clean degradation the L-4 comment assumed. The route now declares `meta: { requiresAdmin: true }` — the exact key `@phlix/ui`'s `authGuard` checks (`to.meta?.requiresAdmin === true`, v0.99.7 already honors it) — so a logged-in non-admin is bounced to home before the page renders, and the anonymous flow goes to login. `web-ui/tests/library-scan-route.test.ts` pins the meta against the LIVE `buildAdminRoutes()` sibling shape, not a frozen string. The independent second layer — `paths?: string[]` on the ui Library view-models + `?.` guards on every library-payload read site — landed in phlix-ui 5c9cf4ed and rides the next tag cascade (the vendored v0.99.7 bundle needs no rebuild for it). The committed `/app` bundle under `public/assets/app` is regenerated from the gated `main.ts` (entry-hash cascade renames every lazy page chunk; byte-deterministic across three consecutive local rebuilds on the CI-paired toolchain). - **Seven security-scan hygiene findings (L-3/L-4/L-5/L-6/L-7/L-8 + the M-3 twin).** **L-3 — anonymous `GET /system/info` stops fingerprinting the runtime stack.** The route carries NO middleware, and it used to answer `php_version` (`PHP_VERSION`) plus `workerman_version` (`Workerman\Worker::VERSION`) beside `server`/`version` — a free CVE-targeting hint to every internet scanner. The estate consumer sweep found exactly ONE reader (`phlix-windows-client` `src/main/versionCheck.ts`, re-targeted in 992c7f0) and it reads only the top-level `version`; the two extras have ZERO consumers, so they are DROPPED outright rather than admin-gated (gating keys nobody fetches adds surface, not safety). `openapi.yaml` declares the payload `additionalProperties: true` with no properties — spec-compatible, no edit needed. Pinned by `SystemInfoPayloadHygieneTest` (full production-container dispatch). **L-4 — library `paths` (absolute filesystem roots) are admin-only.** `LibraryController::index()/show()` and `WebPortalRouter::getLibraries()/getLibrary()` returned the raw row — `paths` included — to any authenticated user, while the sibling media surface has always admin-gated its raw-file `files` block. Consumers of `paths` are exclusively admin surfaces (phlix-ui admin LibrariesPage + api/admin client, console/mobile Admin screens; LibraryScanPage's admin-scoped `v-if` degrades cleanly; roku/tizen/windows read it nowhere), so the key simply stops shipping for non-admins — redaction runs on the local array copy, leaving the LibraryManager process-static cache intact for the next admin. New side-effect- free soft gate `AdminMiddleware::isAdmin()` (NOT `checkAccess()`, which would audit every ordinary non-admin browse as a permission denial); WebPortalRouter reuses its existing `isAdminUser()` predicate. Pinned by `LibraryControllerPathsRedactionTest` + `WebPortalRouterPathsRedactionTest`. **L-5 — the dead-wired `HubJwtMiddleware` is deleted.** Autowired in `HubServicesProvider` but attached to NO route on any dispatch surface (estate-wide grep, 2026-09-30), and its only output `$request->hubUser` was read by nothing — a security mirage beside the live hub-auth path (AccountLinkController/HubTokenController validate via `HubJwtValidator` directly). Class + DI registration + its 18-case test removed (precedent: the `src/Plugin/` deletion b0007060); `Request::$hubUser` stays as a census-pinned RESERVED declared slot with corrected prose. `hub.jwt_invalid` remains emitted (and contracts-registered) from its live sites; the contracts `errors.ts` JWT_INVALID cite's `HubJwtMiddleware.php:73` coordinate is retired in a companion contracts-repo commit. Census re-pinned from red: files 1916→1920, declared reads 423→422, writes 1014→1018. **L-6 — Chromecast 500-handlers stop echoing `getMessage()`.** `stop()`, `seek()` and the shared `controlSession()` (play/pause) returned cast-layer exception text verbatim — which carries the receiver's LAN `host:port` ("Connection refused to 192.168.1.50:8009"). Now constant client messages ('Failed to stop/seek/play/pause cast session') with the detail to the MEDIA log channel, per the d052b488 CastApiClient convention; `ChromecastControllerErrorHygieneTest` pins exactness (assertSame on the constant + assertStringNotContainsString on the host + positive controls so the throwing fixtures can't pass vacuously). **L-7 — SecurityHeaders prose now tells the truth, and the caller-wins guards are case-insensitive.** The class docblock claimed "script/style self-only" and "Base-uri NONE" while the shipped policy carries `style-src 'self' 'unsafe-inline'` and `base-uri 'self'` — the inline-style allowance is DELIBERATE (the SPA themes via `el.style.setProperty()`, i.e. element style attributes), now documented as such; `script-src` remains nonce-only. Separately, the "don't overwrite a caller-set header" guards were case-sensitive `isset()` lookups over Response's exact-case header map: a caller writing lowercase `content-security-policy` was missed and decorate() emitted a SECOND CSP key — browsers enforce the UNION of duplicate policies, silently overriding the caller's intent. Guards now scan with `strcasecmp` (mirroring `Response::asHeadReply()`); pinned by `SecurityHeadersCaseInsensitiveGuardTest` (RED 3/4 pre-fix). **L-8 — `Router::dispatch()`'s six per-request `error_log('[DEBUG] …')` calls are deleted.** Zero test dependencies (the only repo mention is IntegrationDbGuard's comment citing this very hazard: PHPUnit dies on error_log stdout under stdout capture — the ConnectionPool precedent 8e72825d). Gated dispatch ran this hot for every request in production logs; deleting beats env-gating. **M-3 twin — the pre-router direct-play fast path no longer skips stream limiting on a missing `session_id`.** `PreRouterFastPaths::checkStreamLimit()` still carried the middleware's pre-fix bail-out, so any `/media/{id}/stream` client that omitted `session_id` dodged the per-profile concurrency cap before the router (and thus StreamLimitMiddleware) ever ran. It now falls back to the SAME synthetic `(profileId, deviceId)` bucket as the middleware — derivation extracted to the single shared helper `StreamLimitMiddleware::syntheticSessionBucket()` so a device crossing both paths occupies the identical slot; the documented no-device-name pass-through is preserved. Pinned by `PreRouterFastPathsSyntheticSessionBucketTest` (registration/stability/cap-bite/pass-through; RED 3/5 pre-fix). No new error-code literals anywhere (all sites reuse registered codes or keep their existing ones). - **Three security-scan findings (M-1/M-2/M-3, scan @9e765895).** **M-1 — `POST /api/v1/music/scan` is now ADMIN-ONLY.** The handler walks an operator-supplied ABSOLUTE path with synchronous filesystem I/O inside a resident HTTP worker but sat behind bare `AuthMiddleware`, so any authenticated user could stall a worker on an arbitrary tree. It moved into WebPortalRouter's `AdminMiddleware` group, mirroring `LibraryController::scan`'s posture through the same house gate (401 unauthenticated, 403 + permission-denied audit for non-admins, enforced before the handler so a refusal touches zero disk); the route is now one of the FOUR admin-collaborator-conditional registrations, so an unwired construction fails closed to a 404 rather than degrading to auth-only (the S282 lesson). The admin-inline blocking-IO exposure that remains is registered honestly as Exception 7 in `docs/dev/BLOCKING_IO_EXCEPTIONS.md`; a queue was rejected because none exists for raw-path music scans today (out-of-scope new infra). `openapi.yaml` `x-phlix-middleware` flipped to `[AdminMiddleware]`. Pinned by `tests/Unit/Server/WebPortal/MusicScanAdminGateTest.php` + the `WebPortalRouterWirePathGuardTest`/`OpenapiSpecCurrencyTest` cascade. **M-2 — trickplay + chapter-thumbnail asset rails now honor the file's own S235 rating invariant for IDENTIFIED requests.** `MediaItemController::getTrickplay()` and `::getChapterThumbnail()` are registered PUBLIC but returned derived copies of the media (frame-accurate sprites, chapter stills) with no cap check. A Bearer-carrying request over the item's effective rating now gets the exact `show()` 404 (never 403) before any chapter parsing or file I/O, via the new `identifiedRequestIsOverCap()` helper; the DOCUMENTED anonymous posture ("public, no auth required") stays byte-identical because that helper treats an empty user id as not-over-cap rather than reusing `resolveRatingFilter()`'s handler-side deny-all. `realpath` containment on the withFile path was assessed and skipped — the sole writer (`MediaAssetGenerationJob`) only ever emits `<transcodeDir>/chapters/<itemId>/<index>.jpg`, so provenance is safe and adding containment would need new DI plumbing (out of scope). Pinned in `MediaItemControllerParentalTest`. **M-3 — the stream limit no longer skips session-less authenticated streams.** `StreamLimitMiddleware` returned `null` (never registered) when no `session_id` was supplied, so any authenticated client could dodge the per-profile concurrency cap by omitting it. It now falls back to a SYNTHETIC stable bucket `synthetic:<sha256(profileId|deviceId)>` (deviceId already carries its UA-hash fallback): the same device/profile always derives the same id, so its streams occupy exactly one slot that rides the normal heartbeat + 60s stale-GC lifecycle and cannot leak rows. Only a request with no device name at all still passes unregistered. No new error-code literals — a full synthetic bucket hits the same 429 `stream.limit_exceeded`. Pinned in `tests/Unit/Server/Http/Middleware/StreamLimitSyntheticSessionBucketTest.php`. - **The S60 CSP browser control no longer depends on task-queue luck (E2E flake, run 36645892753).** `Fmp4HlsThroughControllerE2ETest::testRemovingBlobFromMediaSrcBlocksPlaybackUnderTheSamePolicy` failed attempt 1 with `Failed asserting that an array contains 'media-src'` and went green on rerun: `tests/Support/Browser/hls-playback-probe.mjs` serialized its report at the first poll that observed `window.__probe.done`, but the browser dispatches the refusal's `securitypolicyviolation` event as its own task, unordered against the hls.js error task that latches `done` — on a loaded runner the violation landed after the snapshot and the control reported "playback failed with no named violation" while the policy was biting exactly as designed. `done` is now the START of a settle, not the end: while the caller's `--timeout` budget runs, the probe keeps re-reading the page until `cspViolations` has been unchanged for a 600 ms quiet window (a late violation restarts it; a policy that refuses nothing costs one window; non-CSP runs never settle). Proven both directions — a synthetic 500 ms delivery delay reddens the OLD loop deterministically with the exact CI signature and the fixed probe catches the same delayed violation (`OK (27 assertions)`); commenting out the CSP header emission still reddens the control via `assertFalse($probe['ok'])`, so the assertion genuinely still proves the block. Test-infra only: no `src/`, and no assertion was weakened. - **`SyncPlayAuthMiddleware::redactTokenQuery()` now masks `token` query params case-insensitively (bearer-law review residual).** Query-name case survives server-side — `parse_str` does NOT lowercase keys, and Workerman's `Request::get('token')` is an exact-key lookup, so `?TOKEN=` never populates the credential read and never authenticates the handshake. The exposure was purely in the logs: the one mismatch-forensics call site (WebSocketServer's `onWebSocketConnect` reject branch) writes the raw URI through `redactTokenQuery`, and a request carrying a live JWT (valid against REST) under a wrong-case name beside a lowercase decoy `?token=` — the exact half-migration shape that fires the mismatch — leaked that real credential past the lowercase-only regex while only the decoy was masked. The pattern gains the `/i` flag on the whole-name match, masking every token-named spelling in the logged URI regardless of case; the cost is mild over-redaction of spellings the exact-key read would never treat as credentials, which is the safe direction on a log-only path (over-redacting a log line is safe; leaking one unmasked spelling is not). Near-miss names (`?mytoken=`, `?MYTOKEN=`) and non-token queries stay byte-identical — pinned alongside the new upper/mixed-case and repeat-param proofs in `tests/Unit/Server/WebSocket/WsAuthenticationTest.php`. - **`NatPmpClient` mapped TCP as UDP (inverted RFC 6886 §3.3 opcodes), returned a refused mapping's zeroed port as success, and padded the §3.2 address request with slack bytes.** The closing half of the wire-fidelity pass a34f698d left documented in-place. (1) §3.3 assigns "1 - Map UDP, 2 - Map TCP" (rfc-editor.org/rfc/rfc6886.txt lines 526-528); the class constants were the other way round, so every TCP forward `PortForwardService::autoConfigure()` requested rode the wire as a UDP mapping request — gateways created wrong-protocol mappings, and the §3.4 teardown keyed by the same inverted constant deleted them again (add/delete flipped together, so the pair stayed self-consistent while never opening the TCP port the API names). The corrected constants finally request the protocol the API advertises; the UPnP-IGD leg of the fallback chain is unaffected (its SOAP `NewProtocol` string was already `TCP`). (2) `mapPort()` now parses the 16-bit Result Code at bytes 2-3 of the §3.3 mapping reply via a new `parseMappingResponse()` before reading the port/lifetime slots: §3.5 mandates zeroed "Mapped External Port" and lifetime on any failure, which the old reader returned as int `0` — `PortForwardService` would then persist the endpoint `ip:0` as a successful forward. Every defined code (1-5) and any undefined code (§3.5: "MUST be treated as fatal errors of the request") now yields null. (3) `buildPublicAddressRequest()` sent 4 bytes against §3.2's 2-byte diagram (`Vers|OP` only, no Reserved field); servers ignore datagram slack so there was no live impact — pinned end-to-end by the unchanged fork-responder round trip — but the request is now spec-true. Deliberately NOT changed: the `NAT_PMP_PORT = 5350` destination (the RFC sends requests to 5351; 5350 is the client-side announcement listener — separate lane, own live-wire decision) and `removePortMapping()`'s ACK check, which still returns true on any opcode-matching reply without gating its §3.5 result code (same class as fixed here, left for its own scoped pass per the unmap decision). Byte-pin tests gained an anti-inversion constant tripwire, a non-symmetric per-protocol opcode pair, the 2-byte address-request shape, and two real socket round trips through the public API (captured on-wire TCP opcode 2 + assigned-port passthrough; refused reply yields null, not `0`). Mutation proofs: re-inverting the constants reddens 5 pins, neutering the mapping gate reddens 7, re-adding the address slack reddens 1, dropping the reply-opcode match guard reddens 1. - **`NatPmpClient` wrote the NAT-PMP mapping request's two port slots swapped, and its public-address parser treated a failed result code as success.** (1) RFC 6886 §3.3 lays the 12-byte mapping request out with the Internal Port at bytes 4-5 and the Suggested External Port at bytes 6-7; `buildMapRequest()` emitted them the other way round. The error self-cancels for the symmetric `addPortMapping($gw, $p, $p)` call PortForwardService makes — equal values in either order — but an anonymous request (suggested external 0) told the gateway the INTERNAL port was 0, and any mismatched pair was wired backwards. `buildUnmapRequest()` needed no byte move: §3.4 keys a deletion by the Internal Port slot and mandates Suggested External Port = 0 (set by the client, ignored by the gateway), so the wire order there was already spec-true; the fix renames the parameter, documents the §3.4 rule, and a new pin forbids mechanically copying the map swap into the deletion path, where internal=0/external=0/lifetime=0 is §3.4's wildcard that deletes EVERY mapping the client owns for the opcode's protocol. (2) `parseExternalIp()` never read the 16-bit Result Code at bytes 2-3, so a refused lookup (§3.5: 1 Unsupported Version, 2 Not Authorized/Refused, 3 Network Failure, 4 Out of resources, 5 Unsupported opcode) still decoded bytes 8-11 — an RFC-compliant zero-filled failure field came back as the success-looking string `0.0.0.0`. The parser now gates `result !== 0` to null (logged) and ignores the address field exactly as §3.2 commands. New byte-pin tests (reflection idiom per `UpnpIgdClientTest`) pin the non-symmetric map request offsets, the zero-suggestion shape, the §3.4 deletion shape, every §3.5 failure code against a non-compliant junk address, and the RFC-shaped success arm; mutation proofs: reverting the map swap reddens 2 pins, neutering the gate reddens 6, and slot-swapping the unmap reddens the deletion pin. Two residuals are documented in-place and deliberately NOT changed here (beyond this pass's wire scope): the class's opcode constants invert the RFC's §3.3 assignment (the class sends TCP=1/UDP=2; §3.3 defines 1=UDP, 2=TCP), and `mapPort()`/`removePortMapping()` read their reply ports/ACKs without the result-code gate. - **`NatPmpClient::parseExternalIp()` read the external IP from the wrong bytes, and `StunClient::getPublicIp()` silently refused every IP-literal server config.** (1) RFC 6886 §3.2 lays the 12-byte public-address reply out as version(1) + opcode(1) + 16-bit result code(2) + Seconds Since Start of Epoch(4) + External IPv4(4): the address is at bytes 8-11, but the parser read bytes 4-7 — the gateway's epoch counter — and the device rework lane's fixture had pinned that deviation. The parser now reads bytes 8-11 with the layout table in its docblock, and `natPmpPublicAddressReply()` is RFC-constructed (opcode 128, non-zero epoch), mutation-proven: reverting the offset reddens the L4 wrong-source test with `0.15.66.64`. The mapping reply needs no change — §3.3 puts the mapped external port at bytes 10-11, which is where `mapPort()` already reads it. (2) `gethostbyname()` returns its input unchanged both for an IP literal and for a failed lookup, so the old equality test read a pre-resolved/numeric `stunServer` as a resolution failure and never probed. New `resolveStunServerIp()` short-circuits IPv4 literals to themselves first and only then treats an unresolved NAME as failure; tests pin all three arms (literal config probes and parses over a real loopback exchange, unresolvable name refuses loudly through the logged gate, name path unaffected). - **Three long-red CI jobs greened (`test-server` PHPUnit, `Psalm-on-tests`, S180 probe), none of them by weakening what they test.** (1) `BackupControllerBodyPersistenceTest::test_supplied_label_reaches_the_persisted_backup_row` turned the MySQL-less `test-server` job red after the mysqldump exit-code strictness fix (`ce295f9d`) made `BackupManager::createDatabaseDump()` fail loudly on an unreachable DB — the old lenient contract had been silently tolerating exactly that. The test now rides the house `RequiresRealDatabase` gate (skip when absent, redden when unusable), and `IntegrationDbGuard` routes `error_log` to `/dev/null` for the probe window only: `ConnectionPool`'s `[DEBUG]` connection-minting lines corrupted a `@runInSeparateProcess` child's captured result stream (`PHPUnit\Framework\Exception: [DEBUG] ...`), a harness artifact, not a database verdict. Adopter census 62→63. (2) `psalm -c psalm-tests.xml` 6→0 errors: non-emptiness narrowing (`assertNotEmpty`) before the three `[count(…) - 1]` tail reads in `RelayConsumerTest`/`SyncPlayManagerTest`, a `/** @var array<int|string, …> */` boundary parse over `consumerState()`'s stream map, and a production docblock spelling in `SsdpDiscovery` — `callable(string): (array{…}|null)` now parenthesizes the inner union, which psalm needs to see the fetcher seam's documented transport-failure `null` instead of reading it as a callable-level union and refusing the very closure the SSRF suite's contract demands (the test was right; the annotation was mis-parsed). (3) The S180 probe's `WebAuthnCeremonyTest::derToRawEcSignature` NOT-REACHED is recorded in `probe-baseline.json`: the probed line is the `self::fail('not a DER integer')` arm of the `$readInt` closure and cannot execute in any green run — its only caller feeds VALID DER, so making the line run would break the behaviour under test. - **A freshly booted worker now reconciles theme state on its first request instead of recording a foreign stamp as its baseline (`S499`).** S498's cross-worker fleet sync adopts the current `plugins`-table stamp on a worker's very first theme request and rebuilds only on a later change — an assumption that holds only while nothing moved between that worker's `bootstrapEnabled()` and its first served request. An audit found the cold-worker hole: when a peer install/enable/disable/uninstall lands in that window, the new worker captures the foreign-current stamp while its in-memory registry is still from the older boot generation, so the two stamps then agree forever and it serves the stale catalogue until the process recycles — the exact W99 symptom, minus the restart that would have hidden it. The stamp now starts as an explicit "never reconciled" sentinel that a real (always-40-hex) stamp can never equal, so the first served request falls through to the existing rebuild-from-durable path and only then adopts the stamp; that boot reconcile is tagged on the log line so an operator can prove, from the log alone, that no worker ever baseline-captured a foreign generation. The extra rebuild cannot duplicate a theme: the rebuild clears then re-registers each enabled source once (a worker that booted current just re-materialises the same bytes), reusing `register()`'s existing replace-per-source idempotency unchanged. The adopted stamp is now committed only after the rebuild completes — a one-line ordering hardening that keeps "stamp equals current" meaning "this process is reconciled to it" under any future request-interleaving model. The theme-arm scope is unchanged; version-unchanged content swaps and settings-driven theme output still do not move the stamp, exactly as before S498. - **A plugin/theme registry mutation is now visible across the whole Workerman pool without a restart (`S498`).** `ThemeSourceRegistry` is per-process by design and `PluginLoader::enable()/disable()/uninstall()` mutate only the ONE worker that served the admin request; a live W99 run measured ~6 of 30 sampled `GET /api/v1/themes` responses still serving an uninstalled plugin's themes until a full service restart. `ThemesController` now carries an optional `ThemeRegistryFleetSync`: before every themes response it folds the shared `plugins` table (`name:enabled:version` per row) into one stamp and, only when that stamp moved since this worker last served, rebuilds this process's registry from durable truth — clear, then re-register the currently enabled theme sources through the same validating door `register()` has always had. The stamp is derived from the very rows every lifecycle mutation already writes, so there is no separate epoch counter to race or lose; the check is one indexed read of a tens-of-rows table on an auth-gated endpoint, and boot's `bootstrapEnabled()` wiring is adopted as the baseline without churn. Convergence is the worker's next theme request — bounded, pool-wide, restart-free — and the served shape is byte-identical (the regression test asserts pooled equality with the pre-install baseline body). The metadata/subtitle/writer registries keep their existing boot-time wiring untouched. - **The merged `merge-junit: … assertions=` total is now declared informational at the emitter, and the one passing test that actually manufactured its run-to-run drift is fixed (`S488`).** Two byte-identical source trees (differing only in `.md`/`.gitignore`) reported `assertions=107654` and `assertions=107652` while `worker files`, `tests`, `errors`, `failures` and `skipped` stayed byte-equal — because the estate quotes the whole line as the CI-verbatim baseline, so a `−2` there reads as a causeless delta. Reproducing the parallel lanes locally and diffing per-testcase JUnit `assertions` attributed it: `CliScanJobVisibilityTest`'s `db()` accessor ran `assertInstanceOf()` on EVERY `waitFor()` poll (once per 50 ms while the SIGTERM cases wait on a real OS-signal / `SELECT SLEEP()` timing boundary), so its executed-assertion count equalled 7 + the poll count and drifted ±1–2 with contention while the test itself stayed green. `db()` now fail-fasts with a `RuntimeException` instead — identical safety (reaching it without a live `Connection` still halts loudly), and the method's assertions are constant (verified 6× under load). Broader than any single test, several Integration lanes legitimately execute a data-driven number of assertions (per-segment loops over real ffmpeg output, per-poll guards), so the emitter now records the known-limit in repo: baseline identity is { worker-file count, tests, errors, failures, skipped } and `assertions=` is informational, printed on the unchanged stdout line plus a new stderr declaration, pinned by `ParallelTestWiringTest::testTheAssertionsCounterIsDeclaredInformationalAtTheEmitter`. - **The teardowns in `tests/Integration/Media/Library/OrphanMusicContainerReapIntegrationTest.php` and `tests/Integration/Media/Transcoding/HlsServingIntegrationTest.php` called `chmod()`/`unlink()` on paths that were already gone, and the parallel gate printed it (`S486`).** Two distinct mechanisms, one shared blind spot: `@` hides a PHP warning from every serial printer — PHPUnit's collector drops suppressed events — while the paraunit gate PRINTER surfaces them, so both defects were invisible locally and loud in CI. OrphanMusic's was same-process and deterministic: `testHealingRescan…` registers the renamed file in `$cleanupFiles` while its parent sits in `$cleanupDirs`, and the old directory pass unlinked every child first, so the blind files pass that ran after it always called `chmod()`/`unlink()` on a vanished path. HlsServing's was the S460 class of race: `ensureSegment()` returns when the published fragment EXISTS, but the detached fMP4 wrapper (`FfmpegRunner::startSegmentEncode`) deletes its `seg-*.part-*` marker and `.m3u8` sibling *after* that, and the blind recursive `unlink()` could scan before the wrapper's trailing `rm -f` and warn ENOENT on the names it lost. OrphanMusic now removes existence-guarded, registered-files-first, and verifies every attempted removal with a fresh stat — a path it could not delete fails loudly naming its lane sentinel instead of stranding a `phlix_s153_it_*` for the census to blame anonymously. HlsServing now adopts the S460 shape: a bounded poll for the expected post-condition (every `seg-*.part-*` marker GONE — the marker is the last thing the wrapper's write phase touches, so its absence means no detached writer can create or delete anything under the job dirs anymore), then a guarded recursive removal with bounded retries, then verification — a surviving directory fails loudly with its entries named. Both `setUp()`s mint their temp dirs with `random_bytes` instead of a name-collision-prone `uniqid()`/unguarded `mkdir()`. No global warning suppression was added anywhere; production code is untouched. Proven on this machine: 10× loud-suppression runs of each class and 3× the real paraunit gate shape (`--parallel=8` over `Integration/Media`) now print zero `No such file or directory` lines and zero residue, while an `LD_PRELOAD` unlink-delay + slow-`rm` venue that deterministically forces the wrapper-vs-teardown interleaving (pre-fix: 2 warnings per run, the exact CI pair) comes back clean; mutation-checking both directions, reintroducing the old blind loops reproduces the CI warning text verbatim and removing the marker poll reopens the race. - **`scripts/parallel/run-suite.sh` graded paraunit's exit status exactly instead of re-exiting any non-zero verbatim (`S485`).** paraunit 2.11.0 maps every test verdict to exactly 0 or 10 (`Runner::onProcessParsingCompleted` — any worker exiting non-zero ⇒ 10), and children run under `failOnWarning=true`, so a warnings-only Integration tail exited 10 and killed CI (run 34651283031 attempt-1: the identical chmod/unlink warning text had passed with exit 0 twice before) while the wrapper's own comment claimed warnings were non-fatal — the comment was the intent, the code was the flake. The verdict is now derived from the artifacts paraunit produces, via a new `paraunit_verdict()` function shared by the run path and a `verdict <status> <log> <junit-dir>` subcommand: statuses outside {0,10} are console/config failures that never reached a child (measured exit 1) and propagate verbatim; under status 10 the lane is fatal when the recap records ERRORS, FAILURES, ABNORMAL TERMINATIONS or RISKY OUTCOME, when the WARNINGS block carries a ` [UNKNOWN]` entry — paraunit's own attribution for runner-level warnings, which is how the S439 zero-residue census lands and it stays red in the parallel lanes — when any JUnit tail holds an `<error>`/`<failure>` element (a PHP warning can never appear in JUnit, so this is the backstop against recap wording drift), or when no JUnit tail exists at all (a gate that cannot read results must not report success); otherwise the warnings-only lane passes deterministically with the warning note still echoed. `tests/Unit/Support/ParaunitVerdictExactnessTest.php` executes the mechanism: five real paraunit scratch runs (warnings, failure, error, clean, census) whose artifact shape is pinned before the verdict is graded — so format drift screams instead of silently demoting — plus two mutation controls on a copy of the script proving the narrowing and the `[UNKNOWN]` route are each load-bearing (reverting either flips exactly the verdict it guards). The file-count census moved 1862→1863 with it. - **`scripts/install.sh` compiled Swoole and php-uv from floating default-branch HEADs while claiming verbatim parity with the pinned `docker/Dockerfile.base` (`S482`).** The bare-metal installer is the one build path an operator's PHP receives third-party source from, and it was the residual S309 explicitly left open outside CI scope: both clones ran `--depth=1` with no ref, so whatever each repo's HEAD was that day got compiled and `make install`ed into the host. Swoole now clones `--branch v6.2.1` — the same release tag the base image's `ARG SWOOLE_REF` names — with a `git rev-parse HEAD` self-verify against the tag's recorded commit, so a force-moved tag fails the install loudly before anything is compiled; shallow-plus-tag is safe where S309 rejected shallow-plus-bare-SHA because a tag always resolves to its own commit. php-uv now uses S309's durable form verbatim: full clone, `git checkout --quiet --detach` onto the same 40-hex commit as the base image's `ARG PHP_UV_REF`, rev-parse self-verify. `ThirdPartyClonePinGuardTest` no longer stops at `.github/workflows/*.yml`: it walks install.sh line-by-line (comments stripped, the existing precedent), sweeps each clone site inside its own shell-function region (one file carries two different pins), asserts the per-site pin forms, counts the clone sites, flags the pre-S482 floating shapes as the anti-vacuity control, and holds install.sh ↔ `Dockerfile.base` refs string-equal — mutation-proven red at each site separately, green at tip. Alongside, `RELEASE_PROCESS.md` was trued against the shipped S474 registry regime (folding in the phlix-server half of `S483`): the promised `v1.2.0`/`v1.2`/`nightly-YYYYMMDD` image tags and the "build and push Helm chart" step never existed, and are now marked NOT-YET-PUBLISHED pending the owner's S274 release-authority decision; rollback documentation moved from a phantom `v1.2.2` image tag to the real `<full-sha>-<variant>` immutable form with an anonymous `tags/list` recipe for finding current SHAs. - **The detached-launch tests in `tests/Unit/Media/Transcoding/FfmpegRunnerHlsTest.php` raced their own cleanup, and a mid-test failure handed the residue to the whole suite (`S460`).** Each launch test mints a `phlix_*` scratch dir under sys temp and lets `FfmpegRunner::startDetached()` run its `if … then touch .complete; trailing…; else touch .failed; fi` chain in a detached `nohup` wrapper; the tests then removed the dir inline with no wait. The wrapper writes the marker *after* the primary command exits (and, for the CMAF trailing case, writes `trailing-ran` only *after* `.complete` appeared, which the bare assertion sampled with no poll), so removal could land first — the wrapper recreated the dir under temp, and the S439 `ZeroResidueCensus` extension (snapshot at bootstrap, diff of `/phlix_*` at `ExecutionFinished`) failed the entire suite for it. That is what crashed W59's first master-CI attempt; the class has been latent since #253 and multiplies under the S457 paraunit shard fan-out. The file now polls the one file each wrapper is contractually expected to write, with a bounded wait and a loud, named timeout failure whose every clause (dir existence, tracked wrapper pid, process-alive probe, observed entries) is re-observed at failure time; scratch dirs are registered per-test and reclaimed in `tearDown()` — so a mid-test failure cleans up too, and removal first waits for the tracked wrapper pid, which under the `nohup`→`timeout`→`sh` chain exits strictly after the last write. Mutation-checked in both directions on this machine: the old shape with a forced mid-test failure leaks the dir and reddens the census; the new shape with the same forced failure leaves zero residue and the census stays silent. Production `FfmpegRunner` is untouched. - **Five real defects in `scripts/`, surfaced by putting the directory under PHPStan level 9 and Psalm (`S256` residual).** None of these files had ever been opened by an analyser, and the tools were right five times: `backfill-chapters.php` passed the raw MySQL connection where `MarkerCandidateRepository`'s constructor demands an `ItemRepository` — a TypeError at boot, the script could never have run past that line; `run-media-asset-worker.php` passed the connection as `FfmpegRunner`'s first parameter, which is the ffmpeg **binary path** (string) — the worker could never have started from this entry point, and it now builds its runner from `config/ffmpeg.php` like every sibling does, with the `ConnectionPool::init()` call the review pass found still missing from it (without it, `getConnection()` throws); `add-headers.php` skipped an excluded directory by `continue`ing on the directory entry itself, which prunes nothing — the traversal descended anyway and rewrote headers inside `node_modules/` and `generated/`, the exact subtrees its own docblock promised to leave alone — while its mid-iteration `setFlags(RecursiveIteratorIterator::CATCH_GET_CHILD)` lands on the inner `FilesystemIterator`, where 16 means something else entirely, and its named `collect()` would fatal on redeclaration against `crell/fp`'s global the moment an autoloader entered the process (the hub's S248 rewrite of this same file is now ported); `backfill-streams.php` read a config key `probe_path` that does not exist — the real key is `ffprobe_path`, so a custom ffprobe binary was silently ignored and `/usr/bin/ffprobe` always won; and `backfill-ratings.php` passed `__LINE__, __FILE__` as positional arguments to `Connection::query()`, binding the line number as PDO's fetch mode, while the `--limit` it documents was interpolated into a SQL string that was then thrown away in favour of an inline batch query — `--limit` never applied (the batching loop now honours it as its usage block advertises, and because the `--execute` candidate set shrinks under every successful upsert, the loop pages by primary key rather than by advancing offset: offset paging would have silently skipped roughly one still-missing candidate for every row the run fixed). The remaining findings were type honesty: mixed-from-`json_decode`/row envelopes parsed into asserted shapes at the boundary instead of offset-cast blindly (`dump-syncplay-envelope-pin-vectors.php`, `backfill-ratings.php`), `$_SERVER['argv']` members asserted as strings rather than `strval`-mapped (`fmp4-rebase-segment.php`), a nullsafe parent lookup (`parallel/merge-junit.php`), a short-name `@var` that resolved to a class the analyser could not see, deleted because the loop analysis proves the variable's type without it (`parallel/merge-coverage.php`), and a ternary that re-read an array offset after narrowing, which Psalm reads differently than PHPStan (`ci-browser-e2e-prereqs.php`). - **A healing rescan now leaves the music hierarchy cleaner than it found it (`S153`).** The S145 repair re-parents a mis-filed track onto the album and artist its tags name and mints those container rows when absent, but the rows the tracks came from were only ever recounted — never removed. Measured on production during the first post-S151 full-read rescan: one complete, successful pass grew `music_albums` from 11,535 rows to 11,596 and took zero-track artists from 13 to 16 while clearing only 50 shell albums. Each healing pass therefore filled shells and minted new ones, so retags accumulate shells indefinitely. The prune pass now also reaps `music_albums` rows with no tracks and `music_artists` rows with no tracks and no albums, attributed to the library through the container's own `media_items` anchor (the `music_*` tables carry no `library_id`), with the zero-children predicate re-proven inside each `DELETE` so no execution of the pass can cascade a live track away through the `ON DELETE CASCADE` foreign keys of migration 065 — a shell album and a populated album that differ by exactly one track is the pinned fixture. A `rescan` runs the same pass automatically when it saw zero read failures, and a standalone `prune` job runs it only when the library's latest completed scan reports zero failures, no scan is live, and every configured root is present right now, because a shell can also be storage that is merely dark; every refusal is logged. `media_items` container rows are deliberately out of scope and survive the containers they anchored. No migration — the `prune` job type and both lookup indexes already exist. - **The pool-concurrency soak's assertion count is now a fixed 27, whatever the load does (S137).** `tests/Integration/Media/Transcoding/PooledConnectionConcurrencyTest.php` asserted INSIDE its reader coroutines: `assertIsArray($e)` ran once per successful reader iteration (up to 1,440 times per run), a failing one threw into the coroutine's own `catch (Throwable)` which aborted that reader's remaining iterations, and the post-join error check added two asserts per recorded error — so the run's total rode machine timing. Measured on this box: `OK (3 tests, 1461 assertions)` clean versus 791 assertions when readers were forced to abort early. A count that moves with load means a run that exercised barely half the invariants could still report green. The per-iteration check is now pure evidence collection inside the coroutine — the reader records which reader id and iteration number saw a non-array entry — gated ONCE after the join over a fixed-size shape, and the error list is scanned once per fingerprint (error 2014, "commands out of sync") instead of twice per error. The class is constant at `OK (3 tests, 27 assertions)` across five consecutive runs and under 48× CPU oversubscription with a second concurrent client hammering the same MySQL; a forced-degraded run through the existing `PHLIX_S106_POOL_ACQUIRE_TIMEOUT` override fails deterministically at the same 16 assertions every time where the old code swung. Detection is unchanged, mutation-proven against `src/` with each mutation reverted after: a pool that never returns its leases reddens the soak on the pool-exhaustion error; an injected `2014 Commands out of sync` failure reddens the named fingerprint assert ahead of the catch-all; a no-op cache invalidation reddens the epoch-guard convergence assert; every read returning null reddens the hoisted row gate; and a fabricated never-written value reddens the corruption gate — several of those REDS also showing the count stay put. The structural lease-lifetime half of the step had already been closed on this base by the per-query-borrow change and the timeout contract of the two earlier pool steps, and with them the assertion-escape probe-baseline exclusion for this file — which this change makes moot again: the file now contains no assertion-bearing closure at all, so the prober can never flip its verdict with the pool's behaviour. Test-only: no `src/`, no config, no baseline edit. - **Three tests that could only ever report the wrong thing now report the truth (S452, S453, S454).** All three were re-derived at tip `75431da6`; all three fixes are test-side — no route, OpenAPI, `src/` or composer file is touched. - **`BackupManagerTest::testCreateBackupGeneratesIdAndPath` ran for real instead of skipping forever (S452).** The case opened with an unconditional `markTestSkipped('createBackup requires actual filesystem and mysqldump …')`, so it skipped on every machine — CI included, where the name sits in the skip set of run 34390345172 — while the job stayed green. Its stated reason was also false: `BackupManager::createDatabaseDump()` throws only when `returnCode !== 0` **and** the dump file does not exist, and the shell `>` redirect creates that file whatever `mysqldump` does, so the step is tolerant by construction and `tar` is the sole external it truly needs. The case now drives `createBackup()` end to end in its own process (`@runInSeparateProcess` + `@preserveGlobalState disabled`, because `PHLIX_CONFIG_DIR`/`PHLIX_DATA_DIR` are constants — the same venue `BackupConfigRecursionTest` and `BackupControllerBodyPersistenceTest` already use), against a scratch config whose `backup.php` points `local_path` at a temp sink and a `database.php` built from `phpunit.xml`'s own `DB_*` env, with the `backups` table mocked. It pins a real RFC-4122 v4 `backup_id`, an archive that exists inside the configured sink under the label prefix, a `tar tzf` listing that contains `database.sql` and `config/backup.php`, `size_bytes` equal to the `filesize()` of that archive, and the S439 staging-directory sweep. Measured on this box before and after: `Tests: 14, Assertions: 23, Skipped: 1` → `OK (14 tests, 35 assertions)`; `scripts/skipped-test-names.sh` over a run of the file now prints zero names. The forced coupling was repaired in the same commit — `SkippedTestNameReportingTest` borrowed that permanent skip as its "exactly one skip" fixture (10 of its 47 cases went red the moment the skip died), and now mints its own one-skip probe into the run's scratch dir, so a guard of the skip-reporting mechanism can never again depend on an unrelated case staying dead. The estate gains no new skip: a full 11,129-test suite run on this box lists ten skips, nine for a missing Chrome/Chromium binary and one for a missing FFI extension, and zero occurrences of `BackupManager`. - **The audiobook traversal test pinned the host's directory layout instead of the path jail (S453).** `AudiobookControllerTest::testStreamAudiobookRejectsPathTraversalEscapingRoot` planted its secret file in `sys_get_temp_dir()` and depended on `LibraryRootGuard::FALLBACK_ROOTS` containing `/home` to keep it outside, with the raw traversal path hardcoding the username `my`. That premise is host-shaped, not code-shaped: with `TMPDIR` under `/home/...` the planted file lands *inside* the fallback jail, the guard answers true, the controller serves it, and the expected refusal becomes a 200 — deterministic red on this box (`Failed asserting that an array contains 200`), green in CI only because `HOME=/home/runner` happens to share the prefix. Jail and escape are now both built inside one directory the test owns and pinned explicitly through `PHLIX_LIBRARY_ROOTS` + `LibraryRootGuard::reset()` (the guard's documented test seam, already used by `PhotoControllerTest` and `BookControllerTest`), with the previous env value restored and the guard reset in a `finally`. The traversal path still carries the jail root as a literal substring while `realpath()`-ing out of it — exactly the shape the pre-guard `str_contains()` allowlist admitted — and an in-jail positive control now proves the guard is not simply refusing everything, so a guard weakened to refuse all and a guard weakened to admit substrings both redden the case. Assertion semantics are unchanged; no username appears anywhere. Green under the default `TMPDIR`, `TMPDIR=/tmp` and a `TMPDIR` under `/home` (7 assertions each), and red again with `LibraryRootGuard`'s containment check reverted to the raw-substring form. - **The music-scanner memory ceiling stopped pinning one PHP build's zval sizes (S454).** `MusicLibraryScannerTest::testMemoryStaysBoundedAcrossALargeTree` compared retained bytes with a fixed 13,600,000 B budget derived from a hardcoded 1,700 B per buffered entry. That figure was a snapshot of the machine that measured it: the same unchanged scanner code retained 11,656,488 B when the constant was written and 13,732,640 B on this box's PHP 8.3.6 — a deterministic red, alone and at 13,983,120 B inside a full suite run, with nothing leaked, green in CI. The primary bound is now a ratio measured in-run: a calibration walk over the bounded window alone (32 albums × 249 files, one short of `MAX_TRACKS_PER_FLUSH`) followed by the 14,000-file worst case in the same process, with the worst case allowed 1.3× the window's own retained bytes. Allocator and temp-path drift cancel between the two measurements, which is what removes the venue dependency; the absolute figure survives only as a deliberately coarse 2,400 B/entry tripwire against per-entry inflation a ratio cannot see, with the pre-S95 whole-tree estimate recomputed from this runtime's measured entry cost rather than a remembered one. Bounded ratios measured here: 1.135× single-file (byte-identical across three runs under both temp dirs), 1.156× with `TMPDIR` under `/home`, 1.155–1.158× inside a full run of the class. Reproducing the pre-S95 retention (`MAX_OPEN_ALBUMS` 32 → 20000) reddens at 2.089× — 24,686,928 B against 11,815,512 B — so the case still fails loudly on a real leak, and no skip was added anywhere. - **The `tests/` PHPStan gate now runs at level 4, every finding fixed at source (S186).** `phpstan-tests.neon` was raised from level 2 to level 4; re-derivation at tip `5986b61d` found 85 level-4 findings — not the ~34 the plan had claimed. Every finding was triaged with per-error mutation evidence and fixed at source across 61 test files: tautological/impossible asserts, dead guards and constant-folded runtime pins, dead catches, by-ref param-out types, and readonly-property adjudications. Zero `ignoreErrors` entries, no baseline; 3 precisely-scoped inline `@phpstan-ignore` adjudications carry their rationale in place. Closed at `e926aec7`. - **Connection-pool concurrency test no longer flakes on the fixed 10 s acquire deadline (S106).** `tests/Integration/Media/Transcoding/PooledConnectionConcurrencyTest.php` (added by `ae4d097d`, S9) built its pools without overriding `PooledMySQLConnection`'s hardcoded 10 s acquire timeout — the deadline `0ad7a080` (S339, the per-query-borrow change that made a writer hold a lease across a whole UPDATE) left at its constructor default — so on durable-default MySQL, exactly CI's `mysql:8.0` service (`innodb_flush_log_at_trx_commit=1`, `sync_binlog=1`), a writer's slow fsync-bound UPDATE could starve a waiter on the deliberately-oversubscribed pool until the deadline threw `pool exhausted: no idle connection available after 10 s` (`98f07479`) with nothing actually leaked. Proven a genuine timing flake, not a code defect: the same branch/container/load gave FAIL then PASS back-to-back, and the assertion count swung 87/443/1457 purely because the run aborted at a nondeterministic point. The fix stays entirely inside the test — `src/` untouched: the `pool()` helper now passes the constructor's already-configurable `$acquireTimeout`, a generous 120 s default, overridable via `PHLIX_S106_POOL_ACQUIRE_TIMEOUT`, and the resolver fails loud on any non-finite or out-of-`(0, 3600]` value instead of silently falling back. A generous ceiling rejects only sub-window fsync JITTER, never a real leak: a stranded lease pins `created` at `maxSize` and every later acquirer still blocks the full window before the pool throws, so the leak detector is window-independent — verified by planting an accepted-but-tight `0.02 s` window, which reddens the soak on `assertSame([], $errors)` with that exact message and re-greens on removal. Determinism is preserved by construction: the run now always completes to the same fixed set of assertions (10/10 consecutive targeted runs at load 6–7, every one `OK (3 tests, 1461 assertions)`) rather than aborting mid-flight — chosen over a measured-latency skip so the class adds ZERO new skips. CI durability config, `phpstan-tests.neon`, and every sibling test are untouched; sibling dir `tests/Integration/Media/Transcoding/` green (41 tests, 2087 assertions). Test/infra only — no `src/`, no routes, no migrations. - **Docker boot gates are now bounded and self-reporting (S210).** `docker-boot-gate` carried no `timeout-minutes`, so the 2026-08-04 PR #623 intel/nvidia legs sat past 34 minutes inside "Boot and smoke-test the image" with no line naming what they waited on, until a human cancelled them — burning the GitHub 360-minute default as failure mode. The R1 pass mined 500 `docker.yml` runs (2026-07-22..2026-09-09, paginated past the ~150-run API window): the "hangs on PR, fine on master" theory is **disproven by measurement** — the same Ubuntu-mirror bandwidth storms degrade master pushes identically (worst SUCCESS anywhere: nvidia PR 4530s on 2026-09-05; same-night master legs 2113s/2818s; #623's last pre-cancel line was a mid-apt `Get:331` in plain-build output), so this is slow silent builds, not a PR-scoped cache defect, and no infinite hang is in evidence. The fix bounds and illuminates instead of guessing at the mirror: `timeout-minutes: 90` on the boot-gate job (1.19x the worst measured success, 9.8x master p95 — the tempting 15–20 min bound was REJECTED because it would have reddened 11 legs that measurably completed, manufacturing false-red pressure); every smoke-script phase banner now carries a UTC timestamp and records into a phase file that a heartbeat subprocess logs against every `HEARTBEAT_INTERVAL` (default 30s) — emitting NO verdict id, so the pinned 20-check name set is untouched; and gate builds run `--progress=plain` under a capability probe, so a mirror storm prints the exact fetch in flight. A permanent wiring guard `tests/Unit/Support/BootGateTimeoutWiringTest.php` pins the bound EXACTLY (quiet edits to 360 or to 15 both redden it), the leg name set, and the heartbeat's no-verdict contract; six planted mutations verified it non-vacuous. KNOWN LIMIT: the bound is calibrated to five weeks of history, not to a promise about the future — a storm worse than any observed will now produce a clean red at 90 min whose last heartbeat and build line name what was waited on, which is the honest failure AC-1 asks for. No check loosened: no skipped legs, no continue-on-error, no assertion lowered; `src/` untouched, zero routes, zero migrations; estate census pin 1838→1839. - **Order-independent relay timer self-skip cases (S266).** Six cases in `tests/Unit/LiveTv/Relay/{HlsRelayManagerTest,HlsSegmentPrefetcherTest}.php` self-skipped on a `Timer::add()` probe, which Workerman v5.2.2 throws only when both `Workerman\Timer::$event` and `Workerman\Worker::getAllWorkers()` are empty. Under the single-process `executionOrder="random"` suite, whichever relay class ran before the first `Worker::$workers` polluter skipped its three cases — order-dependent ±3 noise on the estate-wide skip count, the tripwire that flags a neutered gate. S266 fixes both ends: a new `WorkermanTimerFixture` trait guarantees the Timer static (snapshot/restore around a never-run pure-PHP Select loop), all six self-skip gates deleted so the cases always run; the four polluters (`WebhookServiceTest`, `StreamSessionServiceTest`, `RecordingSchedulerTest`, `WebSocketServerTest`) now snapshot/restore the registry on the `DlnaEnabledGateTest` idiom. A `WorkermanStaticStateLeakGuardTest` guard (+ `StaticProbeBootstrap`) proves the six end with pristine statics in a child process; its positive control must SEE a leaked bare `Worker`. On the lane box the skip name-set is now seed-invariant (pre-fix the relay triples flipped across seeds); a planted `markTestSkipped` moves it by exactly one. Tests/infra only — `src/` untouched, zero routes, zero migrations, `@group workerman @group integration` kept; estate census pin 1835→1838. Suspected polluters `LibraryScanWorkerTest`/`TraktSyncBootRealDbTest` already restore their statics — models, not targets. - **The relay/admin control surface now resolves its config dir absolutely (S211).** Five read sites in `src/Server/Core/Application.php` used `$this->config['_config_dir'] ?? 'config'` — and `_config_dir` is never set in production — so every hub/relay state path silently depended on the process CWD: started anywhere but the install root (manual `php start.php`, a container with a different WORKDIR, cron/debug runs), `RelayStateStore::writeState()`'s `@mkdir` created a stray `./config/` and the operator kill-switch wrote `relay-control.json` where the relay fork would never look — the API still returned success. The supported deployment worked only because systemd/install scripts set `WorkingDirectory`. Fix: a single `Application::resolveConfigDir()` — honors the absolute `_config_dir` test seam, else production `hub.config_dir` (`config/hub.php`, an absolute `__DIR__` — the same source `HealthController` already used), else a documented absolute default derived from `__DIR__`; relative or filesystem-root values throw instead of silently resolving. `RelayStateStore` and `AdminHubController` constructors refuse empty/relative/root config dirs (`AdminHubController`'s own default is now absolute): the admin relay writer and the relay fork reader now provably resolve the same directory — the previous silent-wrong-dir write is impossible by construction. Known limit: an absent `hub.config_dir` diverges from the container provider's fallback chain — unreachable with shipped config, documented as a KNOWN LIMIT in the resolver docblock. New regression test `tests/Unit/Server/Core/ApplicationConfigDirResolutionTest.php` (15 executions): resolution against the REAL `config/server.php` with the process CWD moved to a directory with no `config/` sibling — and no test sets `_config_dir` — plus a writer/relay-fork kill-switch round-trip under a foreign CWD, loud-fail cases, and a comment-stripped source guard with positive controls forbidding resurrection of the relative-fallback shapes. Census `EXPECTED_PHP_FILES` re-pinned 1833→1834. Zero new routes, zero migrations. - **A duplicate storage snapshot is now structurally impossible: migration 105 + an accumulating upsert (S114, S102 fix r1).** `stats_storage` carried no unique key since migration 019, so two concurrent snapshot writers could each store a full five-row generation for the same second. The bare `UNIQUE (recorded_at, media_type, library_id)` alone is INERT on the old schema — `library_id` is nullable and MySQL treats NULLs as distinct in a UNIQUE index (measured on 8.0.46: duplicate NULL tuples accepted with the index live) — while making the column deterministic first turns any plain `INSERT` behind that index DESTRUCTIVE (a rejected or ignored duplicate row is lost data). Migration 105 therefore closes both halves in one pass: it collapses NULL `library_id` onto a `''` "not library-scoped" sentinel and makes the column `CHAR(36) NOT NULL DEFAULT ''`, MERGES every pre-existing `(recorded_at, media_type, library_id)` duplicate onto its `MIN(id)` survivor with SUMmed `item_count`/`total_bytes`/`transcode_cache_bytes` (first-write-wins would silently shrink the dashboard, which S102's reader builds on sums), then adds `UNIQUE KEY uq_stats_storage_recorded_media_library`. The write path in `StatsCollector::recordStorageSnapshots()` becomes an ACCUMULATING upsert (`col = COALESCE(col, 0) + VALUES(col)` — NULL-safe, because 105's merge faithfully keeps an all-NULL legacy group's survivor NULL and bare `NULL + VALUES(col)` would erase the incoming bytes on a fold onto it), so a repeated stamp+bucket+library folds into the existing row and `DashboardService::getStorageSummary()` is byte-identical before and after for any write history. Every mutating statement is behind an information_schema shape probe (103/096/097 idiom), so the file is a true no-op on the ledger-bypassing replays — raw `mysql < file` and a lost ledger row — and converges from a half-applied or inert-index install. The old explicit-NULL seed in the S102 reader test becomes the `''` sentinel (strict mode rejects an explicit INSERT NULL on a NOT NULL column, measured 1048). Proven against real MySQL by `tests/Integration/Stats/StatsStorageUniqueKeyUpsertGuardTest.php`. - **Migration 104 corrects the false `rescan=purge+rescan` claim in the live `library_scan_jobs.type` column COMMENT (S154).** `rescan` never purges the library — it re-reads every file and prunes only items whose source file has gone (`LibraryManager::pruneRemovedItems()`, behind a per-root presence guard). The falsehood had been baked into the column comment since migration 027 and restated by every later ENUM widening (030, 081, 084, 101); it is the copy a DBA sees in `SHOW FULL COLUMNS`, and the one S149's documentation sweep could not reach because it lives in SQL. Migration 104 re-issues the full `MODIFY COLUMN` definition — all nine ENUM members and their ordinals, `NOT NULL`, `DEFAULT 'scan'` and the inherited `utf8mb4_unicode_ci` collation copied byte-for-byte from the live schema — changing ONLY the comment text. Migrations 027/030/081/084/101 are left byte-identical on purpose: their `COMMENT` is executable SQL held in every install's `schema_migrations` ledger, so editing any in place would flip its checksum and re-run the whole ALTER everywhere. Proven against real MySQL by `tests/Integration/Common/Database/RescanEnumCommentGuardTest.php`. - **Four stale claims in `LibraryManager` and the base-image comment now match what the code does (S322 comment-truth).** Three comments asserted that photo and book libraries are scanned for EXIF / EPUB / PDF / CBZ content via `PhotoLibraryManager` / `PhotoScanner` / `BookScanner`. They are not: `scanPhotoLibrary()` and `scanBookLibrary()` route both types through the shared `MediaScanner` (`$this->scanner->scan(…, 'image'|'book', …)`), and neither the photo-EXIF nor the book-harvest path is reachable from here — the manager/scanner classes are not even imported. The comments now state the harvest path is DORMANT rather than pretending it is live (wiring the photo route to `PhotoLibraryManager` is a separate behaviour change with its own scan-path blast radius, explicitly NOT this step). The three genuinely-unused `use` lines (`MusicLibraryType`, `BookLibraryType`, `AudiobookLibraryType` — the plan's "two" undercounts by one, and nothing in the estate's toolchain flags an unused import) are removed; the kept imports were re-derived by a tokenized (`php -w`, comments stripped) source census rather than a `use`-grep alone. Separately, `docker/Dockerfile.base`'s `ext-exif` comment claimed *"every photo/PDF scan in the published image would have fatalled"*; corrected to the measured truth — those two `@exif_read_data` call sites are dormant as shipped so none fatalled, while `ext-exif` stays a declared hard platform requirement that the missing extension still failed at `composer check-platform-reqs`. No behaviour change: the `src/` diff is comments plus three imports only. - **Migration 103 collapses the music tables' duplicate UNIQUE indexes to one named constraint each (S155).** `070_fix_music_fk_types.sql` declares its fixed columns with a column-level inline `UNIQUE` inside `MODIFY COLUMN` (070:28,35,42); MySQL auto-names such an index, so a replay never collides, never raises 1061, and `MigrationRunner`'s idempotent squelch never fires — every re-application minted ONE MORE unique index over the same `media_item_id` column (production carried 24 per table; even a first chain pass lands on two: 065's own inline `UNIQUE` + 070's re-mint). 070 is untouched — its checksum is in every install's ledger — and 103 instead ensures the named `uq_music_{artists,albums,tracks}_media_item`, drops every other unique over exactly that column in one multi-drop ALTER (FK-safe: the survivor backs `fk_*_media_item` before the final state is validated — measured on MySQL 8.0.46; the constraints themselves are never dropped, since 1553/1826 are not in the squelch set), and follows the S161 shape standard: a same-named NON-UNIQUE imposter is replaced, never skipped, and a duplicate-dirty table refuses loudly with the remedy in the error text, altering nothing, so 103 also self-heals on empty-ledger transitions (070 re-mints before 103 collapses in the same pass). Proven by `MusicMediaItemUniqueIndexGuardTest` (9 real-MySQL tests: collapse, silent replay, re-mint self-healing, post-collapse 1452 FK + 1062 unique INSERT probes, imposter replacement, dirty refusal, column-set scoping) and by twice-replayed from-scratch chain runs. No routes touched; `ROUTE_MANIFEST` byte-identical. - **False historical claims in the `MusicLibraryType::getScanner()` narrowing comment (S132, W42 rescope; comments only).** The docblock cited `MediaScanner.php:290` for a constructor that lives at `src/Media/Library/MediaScanner.php:483`, claimed `createDefaultLogger()` "mints its own `/tmp/phlix_media_*` directory" when since the temp-dir minter was retired (unnumbered S167 arm, 1d505406 + f53c8567; S439, e4853f0f) it returns the shared `LoggerFactory::get(LogChannels::MEDIA)` logger, and deferred to "S132 … the blocker" for a future constructor widening that the W42 rescope retired — the signature was never widened and needs no un-blocker. The prose now matches shipped code: the narrowing is forced by the current `?StructuredLogger $logger = null` parameter. No behavior change; no test pins this prose (`git grep -F 'S132' tests/` is empty by design, and stays so). - **Composer `ClassLoader` autoload leak per plugin disable/uninstall cycle (S166).** `PluginLoader::wire()` and `getEntryInstance()` `require_once`-d each installed plugin's generated `vendor/autoload.php`, which registers a `ClassLoader` on the `spl_autoload` chain, and nothing ever unregistered it. In a resident Workerman/Swoole worker every enable→disable→uninstall cycle appended another dead loader — consulted on every future class-resolution miss and, post-uninstall, pointing at a deleted directory for the life of the worker. The loader now diffs `spl_autoload_functions()` around each require, keeps the exact captured callables per plugin (object identity, never a stringified name) and detaches them on `disable()`/`uninstall()` — including the lazy `getEntryInstance()` path, which leaked without any enable cycle. Re-enable in the same process re-attaches the captured handles, since `require_once` cannot re-fire the already-included file. Pinned by three process-isolated invariant tests driving the real shipped sample theme plugin in `tests/Integration/Plugins/SampleThemeLifecycleTest.php`. - **Shape-aware UNIQUE-index detection in migrations 096/097 (S161).** Both chain-owned index migrations (and `PlaybackStateDeduper::hasUniqueKey()`/`addUniqueKey()`) decided "already applied?" by matching `INDEX_NAME` alone, so a same-named NON-UNIQUE index made the file no-op and record success forever while duplicates survived and the upsert could never fire — the original S156 defect made permanently invisible. The probe now requires `NON_UNIQUE = 0` plus the exact column set: an equivalent UNIQUE under a different name is recognised (no second, redundant index), and a same-named wrong-shape imposter is dropped and replaced in one atomic ALTER. Pinned in both directions against real MySQL by `tests/Integration/Common/Database/UniqueIndexShapeGuardTest.php`. - **S439 minter gap surfaced by this lane's CI park (S227).** `AccessScheduleHeadNoBodyWireTest` boots the real `Application` in a spawned worker, which mints the shared `/tmp/phlix_media_asset_jobs` + `/tmp/phlix_similarity_jobs` queue dirs without owning a sweep; under `executionOrder="random"` its residue survived only until a later sweeper test ran — and on an unlucky seed it survived the whole `Server Component Tests` run and tripped the zero-residue census. The minter now owns its sweep (same S439 block the other nine minters carry), and `ApplicationTest` — the suite's other MySQL-dependent minter — gets the identical block. Product untouched; the deeper lazy-mkdir-in-ctor fix stays the documented follow-up. - **Poster, trailer and logo keys pass the S101 artwork URL allowlist too (S112).** In `MediaItemShaper`, `poster_url` — the whole fallback chain including the `cover_image_large`/`cover_image_extralarge` legs — the stored `poster_srcset`, `trailer_url`, `logo_url` and the nested `production_companies[].logo_url` now all run through the same scheme allowlist (`safeImageUrl()`/`safeImageSrcset()`) S101 introduced for the backdrop keys; legitimate values are byte-identical on both the list and detail shapes (proven by a value-identity harness plus per-key matrices). The `logo_url` guard runs before `SignedUrl::refreshArtworkUrl()`, so signed internal artwork URLs still re-mint (closing the untrimmed-value skip that shipped expired signatures), and a rejected poster candidate falls through to the next fallback instead of being echoed. - **`SignedUrl::refreshArtworkUrl()` re-mints from RAW query bytes only (S449).** The re-mint ran `parse_str()` over the stored query and re-emitted the DECODED size into a freshly signed URL, so a stored `size=%22onmouseover%3D…` — which the S112 emission guards pass because they blacklist literal quote/angle/backtick bytes, absent from the encoded form — came back out as a validly signed link carrying the literal breakout toward every consumer. The benign corruption shared the mechanism: a `+` turned into a space and a double-encoding lost one level per pass, breaking byte identity without any malice. The size is now extracted from the raw query bytes and re-minted only when the capture is byte-identical to its urldecoded form; anything that drifts on decode (hostile, `+`-bearing, double-encoded) passes through untouched, its stale signature inert at the serving gate. Canonical plain sizes keep re-minting byte-identically, `verify()` is untouched (the size was never signed, per the `PreRouterFastPaths` doctrine) and no caller changed. Pinned by a 12-test matrix in `SignedUrlTest` — plain re-mint regression, hostile encoded, `+`, double-encoded, inert-stale-token, byte-no-op encodings and the end-to-end shaper-chain composition — with a mutation proof: reverting the guard turns 10 named tests red. - **`Uuid::v4()` entropy moved from `mt_rand()` to the CSPRNG (S443).** The shared UUID mint — every `CHAR(36)` primary key on the Music scan path (`MusicLibraryScanner::createMediaItem()`) and the `ItemRepository::create()` fallback — drew all 122 random bits from the process-global Mersenne Twister. PHPUnit's runner re-seeds that same engine for `executionOrder="random"` (`mt_srand(randomOrderSeed)`), the order `shuffle()` consumes it, and tests re-pin it mid-run (jitter/fixture determinism) — so a pinned `--random-order-seed` rerun, or one seeding test between two minting consumers, replayed byte-identical PKs and the second INSERT died with MySQL 1062 `Duplicate entry … for key 'PRIMARY'` (the intermittent Music red from the s252 suite run). `v4()` now draws 16 bytes from `random_bytes()` and sets the RFC 4122 version/variant nibbles — the same S111/S334 conversion the test-side `FixtureIdGenerator` got, applied to the production source. Nine test-local `sprintf(mt_rand(...))` uuid copies were swept to the single shared generator in the same commit. No test relied on seeded-uuid reproducibility (the seeded consumers — webhook retry jitter — draw `mt_rand()` at their own call sites and are unaffected). Pinned by `tests/Unit/Common/UuidOrderSeedFuzzTest.php` (named order-seed fuzz: reddens element-for-element on any revert to `mt_rand`, greens deterministically after) and `tests/Integration/Media/Music/UuidSeedReplayPkCollisionIntegrationTest.php` (the same replay against a real `media_items.PRIMARY`; S431 census re-pinned 1787→1789, S126 adopters 51→52). - **Auth-provider list emits `live`/`enabled` badge signals over the TOGGLEABLE universe (S252).** `AuthProviderController::listProviders()` iterated the registry — by construction only enabled+configured providers — so a configured-but-disabled provider was absent entirely and `enabled: false` was unrepresentable, while the S44-a UI's strict `p?.live === true` read everything as Disabled. The list is now `AuthProviderBootstrapper::TOGGLEABLE` (oidc, ldap, github) with two computed booleans per row: `live` ← `AuthProviderRegistry::hasProvider()`, `enabled` ← `AuthProviderBootstrapper::isEnabled()`. Pinned by `tests/Unit/Auth/AuthProviderBadgeSignalsTest.php`. - **S14 HIDE finished on the two missed blank-row surfaces (S220).** `DashboardService::getRecentPlaybackEvents()` emitted `username => null` / `details.media_title => null` activity rows after user/item deletion, and `NewsletterGenerator::getTopMedia()` rendered deleted items as the literal `'Unknown'` row (`LEFT JOIN` + `COALESCE`) — the exact placeholder S14's decision rejected. Both now hide orphaned rows (null-skip / INNER JOIN), matching the S14-fixed Top Users / Top Media cards. Pinned against real MySQL by `tests/Integration/Admin/BlankRowHideRealDbIntegrationTest.php`; sibling auth/library feeds' nullable usernames are enumerated out of scope. - **Route parameters are percent-decoded ONCE at the routing boundary (S435).** The Router compiled `{param}` to `(?P<$1>[^/]+)` and handed handlers the RAW encoded segment, so `GET /api/v1/music/albums/Abbey%20Road` looked up an album literally named `Abbey%20Road` — `MusicController::getArtist()`/`getAlbum()` passed the still-encoded value into `MusicLibraryService::findArtistByName()`'s `WHERE a.name = ?`, and every artist/album with a space, accent or unicode name 404'd library-wide. `Router::dispatch()` and `Router::dispatchAsHead()` (the estate's only two param-extraction sites — `pathParams` is written nowhere else) now decode through one private `decodePathParams()` **after** pattern matching: the raw path keeps its routing meaning (`%2F` cannot split segments or forge routes, and upstream traversal guards keep vetting the exact bytes they saw), handlers get parsed names, and no handler decodes again — the `%2520` fixture resolves the artist whose name literally contains `%20`, never the space-named one. Policy: `rawurldecode()`, not `urldecode()` — a `+` in a path segment is a literal plus (RFC 3986); `+`-means-space is the query-string form encoding; malformed sequences (`%ZZ`, trailing `%`) pass through verbatim without throwing. Additive: every existing route path still matches; the console-client pin asserting the encoded wire form (`/api/v1/music/albums/Abbey%20Road`) stays correct — the server now decodes on receipt. Pinned by `tests/Unit/Server/Http/RouterPathParamDecodingTest.php` and, against real MySQL through the composed `Application::loadMusicRoutes()` table direct AND over the real `RelayConsumer::buildRequest()` relay path, `tests/Integration/Server/Http/MusicEncodedRouteParamE2eTest.php` (S431 census re-pinned 1769→1771, declared-write denominator 940→949 — all writes on declared members). - **The curated coroutine-hook allowlist is now enforced and DELIVERY-PROVEN at worker start (S433, was S255).** Workerman's `Events\Swoole` constructor re-installs `SWOOLE_HOOK_ALL` per worker, and `start.php`'s remedy — `Coroutine::set(['hook_flags' => …])` from inside the `onWorkerStart` coroutine — measured on this estate's stack, updates only the REPORTED mask: `getOptions()` reads the curated `0x42fe` while the `NATIVE_CURL`/`FILE`/`PROC` handlers stay physically installed, so the SIGSEGV mitigation could reach no worker and the standard check reported success either way (the `EventLoopTls.php:44-64` docblock testifies). Per-worker enforcement now goes through the full-mask replacement API and a behavioural verdict: new `Phlix\Server\Runtime\HookDelivery` calls `Swoole\Runtime::enableCoroutine()` (the only API measured to un-swap handlers, and — narrowing S255's fix-direction note — from inside the worker coroutine too; the API, not the calling context, is the variable) and then proves the configured mask is the mask in force with a sibling-tick probe (a cURL to a never-answered local listener that either blocks the worker or yields dozens of ticks; `getOptions()` is never consulted). Non-delivery and inconclusive samples throw `HookDeliveryException` — startup fails LOUD, and the background-timer worker's best-effort `catch (\Throwable)` now rethrows exactly that exception instead of warning past it. The `coroutine.enabled=false` and `coroutine.hook_flags` config paths are now true: mask `0` is enforced (the shipped default left `ALL` installed even with the hook disabled) and an operator mask that re-enables a cURL hook verifies green — the check is two-sided, and its negative half (config claims a yielding cURL over a physically blocking worker → throw) runs on every Swoole build, while the positive half measures which cURL bit the build actually delivers before asserting (the CI PECL build refuses runtime emulated-cURL installation outright — measured, S433). New `CuratedHookDeliveryProbeTest` (7 tests, real Swoole 6.2.1, reproduces the vendor adapter constructor's `ALL`-installed state order-independently via the authoritative API; skips without ext-swoole/ ext-curl) + `HookAllowlistEnforcementGuardTest` (5 tokenized guards incl. the six-worker census); `SwooleRuntimeTest` gains 3 pure tests for `runtimeHookMask()`/`curlHookFlags()`. Planted-drift proof: replacing `verify()`'s tick comparison with the `getOptions()` equality reddens `CuratedHookDeliveryProbeTest::test_the_old_in_coroutine_set_lies_and_the_probe_catches_it` and the guard's no-`getOptions` rule; restore → green. The probe class's own teardown zeroes the PHYSICAL hook state rather than restoring the reported option baseline — the same lie-API trap, re-encountered in CI's random order as cross-test `proc_open` contamination and measured away (`AccessScheduleHeadNoBodyWireTest` now co-scheduled green in `tests/Unit/Server`). `EventLoopTls`'s registered blocking-I/O exception note updated: with the allowlist enforced the documented bounded stall — not the accidental yield — is the live behavior. The S431 executable census denominator re-pins 1,760→1,764 in this same commit (four new first-party files; every other pin untouched, exactly as the re-pin protocol demands). No `SAFE_HOOK_NAMES` change; no migration. - **S271 mutation-arm description corrected (S428, server leg).** The `BackupControllerBodyPersistenceTest` header claimed reverting either dead-property arm "reddens exactly one named test (mutation-verified)". Both arms re-run at the S427 tip prove the claim asymmetric: the `create()` arm reddens exactly the one named label test (string-diff `-'nightly-pre-fix' +''`), while the `updateSchedule()` arm reddens TWO — the named schedule test (`7 is identical to 9`) plus the collateral non-numeric-validation test (`200 is identical to 400`), because the dead read also disarms that handler's 400 guards. Comment-only; no test or product code changed. - **The Trakt pull-sync timer has a structural boot catch-up (S340).** `start.php` armed the worker-0 Trakt→Phlix watched-history pull on a bare `Timer::add($intervalMinutes * 60, …)` — first tick a full interval after process start, countdown reset on every boot — so on a box restarted (deploy, reboot, the admin Restart button) more often than the interval the pull never ran, the last live instance of the boot-catch-up defect. The timer is now a 60-second sweep delegating to the new `TraktSyncBoot` (`src/Server/Integrations/Trakt/TraktSyncBoot.php`), which holds the pure due-decision, persists the last completed pull in `server_settings` (`trakt.sync_last_run_at`), and runs the pull only when the configured interval has genuinely elapsed — the S308 structural due-check shape, not the one-shot sibling shape. The first sweep a minute after every boot catches up exactly when a poll was missed; a pull longer than the interval cannot re-trigger (the persisted stamp is the completion time). The plugin (an external, non-vendored package) is injected as the sync callback, so the testable surface is the due-decision itself: unit tests enumerate every branch, the real-MySQL integration test simulates the restart (stale last-run → sync runs on the first sweep, fresh last-run → no re-run, last-run survives restart), and a real event-loop control observes the pre-fix bare interval NOT firing within an uptime shorter than the interval. A comment-stripped structural pin (`TraktSyncBootWiringTest`) keeps `start.php` delegating to the gate, since `start.php` itself runs outside CI. - **DLNA Browse now advertises a stream URL that actually resolves, and a `protocolInfo` that matches the bytes (S53).** `<res>` was built by `LibraryBridge::getStreamUrl()` → `HlsStreamer`, which emitted `{hls.base_url}/hls/{mediaItemId}/playlist.m3u8`. That URL is not merely "not found": measured against the composed route table it **does** match the registered `/hls/{job_id}/{file}` route — it is *mis*routed. It passes a media-item id where a transcode **job** id belongs, names a playlist file that does not exist, sits behind `SignedUrlMiddleware` (which a renderer, having no credentials concept at all, can never satisfy), and defaults to a `localhost` a TV resolves to itself. Every DLNA item was therefore browsable and unplayable. `<res>` now points at `DlnaRoutes::STREAM_PATTERN` — the authless, allowlist-gated, Range-serving `/dlna/stream/{id}` route S52 shipped, whose `DlnaRoutes::stream()` builder had had **zero callers in `src/`** until now. `DlnaResUrlIsRoutableTest` proves it end to end the way a renderer does: it reads the URL out of a real Browse response, resolves its path against the **production** route table (exact pattern equality — a substring test cannot tell a sibling wildcard from the real route), then feeds the same URL back through `Application::dispatch()` from an allowlisted peer and requires the file's bytes, under the exact MIME the same `<res>` advertised. - **`protocolInfo` no longer claims an H.264 profile for every video (S53).** `ContentDirectory::getProtocolInfo()` asserted `DLNA.ORG_PN=AVC_MP4_MP_HD` for anything typed `video`/`movie` — a `.mkv`, an `.avi`, an MPEG-2 `.ts` alike. A renderer that trusts a `DLNA.ORG_PN` starts a decoder for that exact profile and fails mid-stream. A profile is now claimed only where the container settles the question, and the MIME is resolved through `DlnaMimeTypes` by the same decisions, in the same order, that `DlnaStreamController` uses to pick the served `Content-Type` — including the direct-play gate, so a `.iso` typed `movie` is no longer announced `video/mp4` and then answered `415`. `DLNA.ORG_OP=01;DLNA.ORG_CI=0` are now advertised, and are facts about that route: byte-seek yes, time-seek no, no transcode. - **The DLNA type table is exhaustive over the 13-member `media_items.type` ENUM (S53).** `episode`, `track` and `audiobook` — the types the TV, music and audiobook scanners actually write — all fell to a `default` arm, and a dead `'image'` arm pretended to cover stills (the ENUM member is `photo`). `DlnaMimeTypes::TYPE_FALLBACK_MIME` now carries one entry per member, keyed off `MediaItemType::ALL`, and `DlnaTypeCoverageTest` reddens if a 14th member is ever added without a DLNA answer. - **🚨 A DLNA Browse response no longer leaks absolute filesystem paths (S53).** `ContentDirectory::addItemMetadata()` ended in a no-`LibraryBridge` fallback that emitted `$item['path']` — the media file's absolute on-disk path — as the `<res>` value. A CDS Browse carries no authentication (the only gate is `DlnaAllowlistMiddleware`'s IP allowlist), so that disclosed the server's directory layout to anything on the LAN, and it was not even playable. The arm is deleted: a bridge-less ContentDirectory serves stub data and has nothing streamable to point at, so it now correctly emits no `<res>` at all. - **`dlna.advertise_host` is honoured by the SSDP `LOCATION` header (S53).** `start.php` constructs `new SsdpAdvertiser(null, …)`, so `getIpAddress()` ignored the setting entirely and always auto-detected, while the device description **did** honour it. The two agreed only by coincidence under the shipped default of `''`; the moment an operator set the key — exactly what `config/dlna.php` tells them to do on a multi-homed or Docker host — a control point fetched the description from one host and every URL inside it named another. The setting is now read in one place, `Phlix\Dlna\DlnaAdvertisedHost`, by all three of the `LOCATION` header, the description's service URLs and the `<res>` stream URL. - **Relayed browse can render posters and avatars again (S238).** `GET /api/v1/artwork/{id}` and `GET /api/v1/users/{id}/avatar` were private pre-router methods on `HttpHandler` and appear in **no** route table. `Hub\RelayRequestDispatcher` consults only the two route tables, so measured through the real composed container (345 `Application` routes + 47 `WebPortalRouter` routes) both paths answered **404** over the hub tunnel and a relayed inline-browse could show **no images at all**. The gate here is **not** the one S164 found for `/media/{id}/stream`. Both of these paths do begin with `/api/`, so the `WebPortalRouter` second-chance fallback fires for them — and 404s as well, because the route is absent from both tables. So these two have exactly **one** gate (missing registration) where `/media/{id}/stream` has two. The `/api/v1/media/1` control answers **401** through that same fallback, which is what makes the 404s mean "no such route" rather than "auth rejected before routing". Both endpoints now live in the transport-neutral `Server\Http\FastPath\PreRouterFastPaths`, consulted at the same pipeline position by **both** entry points: `HttpHandler::__invoke()` (after auth, before the router) and `RelayRequestDispatcher::dispatch()` (after the DLNA hard deny, before the router). One implementation, so the two transports cannot drift. Bytes still stream lazily on both: `Response::toWorkermanResponse()` hands the file to Workerman's event-loop `withFile()`, and `RelayConsumer::streamFileChunks()` chunks it over the tunnel. **Auth is unchanged and was proved on both sides separately:** each endpoint still authorises inline on a resolved session **or** a valid signed URL, before anything is stat'd or streamed. An anonymous relayed request with no signature is still **401** on both paths, and a tampered signature is still refused. `/media/{id}/stream`, the third pre-router fast path, is **deliberately left where it is**: whether direct-play should be relayable at all is S164's open product question, and moving it would decide it silently while starting to carry whole video files over the tunnel. - **Avatars now declare `image/jpeg` instead of `application/octet-stream` (found while doing S238).** `HttpHandler::serveUserAvatar()` passed the bare extension `"jpg"` to a helper that begins by running `pathinfo(..., PATHINFO_EXTENSION)` of its own; on a dot-less `"jpg"` that yields `""`, so no map entry ever matched and **every** avatar was served as `application/octet-stream`. Browsers rendered them anyway only because these fast-path responses carry no `X-Content-Type-Options: nosniff` — a guarantee that need not survive an intermediary such as the relay this change makes the endpoint reachable through. `AvatarStorage` re-encodes every upload to JPEG and `path()` only ever returns `<userId>.jpg`, so the type is a constant. - **Reconciled the four version sources and repaired `scripts/release.sh` (S74 follow-up).** `c17bb9ec` ("Release v1.2.3", 2026-07-12) bumped `k8s/helm/phlix/Chart.yaml` to 1.2.3 and pushed the annotated tag `v1.2.3`, but never touched `Phlix\Common\Version::STRING`, which stayed at 1.2.2 — so for three weeks the Helm chart, the git tag and the version the running server reported over its own API disagreed, silently. `Version::STRING` and the root `VERSION` marker are now **1.2.3**, matching the published tag and the chart. `src/Common/Version.php::STRING` is now documented and enforced as the single authoritative source. `scripts/release.sh` was rewritten to read it and bump **every** mirror in one commit (`Version.php`, `VERSION`, `Chart.yaml` `version:` + `appVersion:`); it refuses to release from a tree whose sources disagree, and it never writes a `composer.json` `version` field — that field makes `composer validate --strict` (the `composer-validate` CI job) exit non-zero, which is why `f5375f7a` deleted it. The old script had been broken since that same `f5375f7a`: it read the version from the composer key that had just been deleted, so it computed the empty string and produced `New version: ..1`, wrote `version: ..1` into `Chart.yaml`, committed it, and then aborted on `fatal: 'v..1' is not a valid tag name` — after the commit. It also parsed `--dry-run` only in argument position 2, ran relative `sed`s against the caller's working directory, and swept any already-staged file into the release commit. All four are fixed. New pins: `tests/Unit/Server/Updates/VersionSourcesAgreeTest.php` (every source agrees; composer.json declares no version field; the script's managed-file list matches) and `tests/Unit/Scripts/ReleaseScriptTest.php` (runs the script for real in a throwaway git sandbox). `RELEASE_PROCESS.md` no longer instructs releasers to hand-edit `composer.json`. - **S120 AC audit — the assertion-escape guard was one deletable line from silent, and the defect class had already recurred.** Auditing S120 by planting escapes rather than by reading the guard turned up three things a green CI run could not have shown. - **A live escape that shipped after S120.** `SmartPlaylistRefreshSubscriberTest::test_drain_tick_refreshes_the_linked_smart_collections` asserted inside a `willReturnCallback` reached through `SmartPlaylistRefreshSubscriber::drainTick()`, which wraps `refreshLibrary()` in `catch (Throwable)` so a failed refresh cannot kill the worker's timer. Breaking the production line the test pinned (`SmartPlaylistRepository::findByLibraryId()`'s bound parameter) turned the test red on `refreshSmartCollection … called 0 times` — naming neither the query nor the binding. Fixed with S102's remedy (the callback RECORDS, the assertions run OUTSIDE it); the same mutation now fails on *"the smart-playlist lookup must be scoped to the drained library"* with the actual diff. - **Deleting one line from `phpunit.xml` disabled the whole guard, silently.** With the `<extensions><bootstrap>` registration removed, a run containing twelve deliberately planted escapes — eight of which the guard reported a moment earlier — wrote no report and `php scripts/assertion-escape-check.php` printed "no escapes reported" and exited 0. Deleting the workflow step was equally silent. New `tests/Unit/Support/AssertionEscapeGuardWiringTest.php` closes both, plus the check script's two exit paths; each of its assertions was verified by mutating the line it protects (registration deleted, registration commented out, CI step removed, `continue-on-error` added, `if: always()` dropped, check step moved ahead of PHPUnit, `exit(1)` → `exit(0)`, script deleted — eight mutants, eight named failures). - **A fourth blind spot, previously undocumented.** `FAILED_OUTCOME_BUDGET = 1` assumes a failing test failed *through* an `assertThat()` assertion. When the visible failure is `Assert::fail()` or a mock parameter/invocation rule — neither of which emits an event — the budget goes unspent and absorbs one genuinely swallowed assertion. That is exactly how the escape above stayed invisible. Documented rather than closed: separating the two would need message correlation between `AssertionFailed` and `Failed::throwable()`, whose texts differ by construction, and a heuristic that mis-fires reports healthy tests as vacuous. `scripts/assertion-escape-audit.php --probe` decides the same site correctly (`DEGRADED`, exit 1) with no heuristic. - **Assertions inside a callback can no longer pass a test while asserting `false`** (S120). PHPUnit's `ExpectationFailedException` extends `AssertionFailedError` extends `PHPUnit\Framework\Exception` extends `RuntimeException`, so any assertion that runs inside a callback which is invoked under `try { … } catch (\Throwable)` or `catch (\RuntimeException)` is caught by the code doing the catching and never reaches PHPUnit. A 24-site census over the 699 `.php` files then under `tests/` — each site decided by planting a tripwire and reading the run, not by grep — found **2 fully vacuous** tests (`TranscodeManagerTest::testSegmentWaiterCountReturnsToZeroWhenPollBodyThrows`, `::testInFlightCounterReleasedWhenLaunchThrowsBeforePolling`: green with `assertTrue(false)` planted, and their own `assertNotNull($thrown, …)` was **satisfied by the swallowed failure** — verbatim, the two messages were `'the exception propagates, it is not swallowed'` and `'the RuntimeException must propagate, not be swallowed'`, at `tests/Unit/Media/Transcoding/TranscodeManagerTest.php:3716` and `:4868` before this change) and **2 swallowed-but-red** ones whose diagnostics were degraded (`LibraryScanCommandTest::testProgressTicksStreamOntoTheJobRow`, swallowed by `LibraryScanCommand.php:246`; `StatsCollectorTest::testRecordStorageSnapshotsSumsEveryTypeThatSharesABucket`). All four now follow the established remedy — **the callback RECORDS, the assertions run OUTSIDE it** — and the two `TranscodeManagerTest` tests gained a `catch (AssertionFailedError) { throw $e; }` arm ahead of their `catch (\RuntimeException)` so a future in-callback assertion cannot be swallowed either. The other **20** sites were left untouched: 18 whose tripwire executed and took the test red with its own message, and 2 whose tripwire never executed — **undecided by the probe, not verified**. Every one of these numbers is MEASURED by running `php scripts/assertion-escape-audit.php --probe`, and one row is environment-dependent: the Integration site (`PooledConnectionConcurrencyTest.php:392`, also a known pre-existing flake under coroutine churn) self-skips without a live database, so it reports `NOT-REACHED` on a box with no DB and has been observed as `GATES` on one with. A run on a DB-less box therefore prints `20 site(s) probed — 17 GATES, 3 NOT-REACHED, 0 VACUOUS, 0 DEGRADED`. Re-run the probe rather than quoting a remembered count. - **A fresh install now actually HAS the `playback_state` progress-upsert unique key** (S156). Migration `090_playback_state_session_media_unique.sql` is NAMED for `uq_playback_state_session_media (session_id, media_item_id)` but carries no executable statement at all — it is a pure comment block that reserves the number and defers the key to `migrations/cleanup_090.php`. That script is MANUAL: no auto-run path — not `scripts/run-migrations.php`, not `bin/phlix migrate`, not `scripts/install.sh`, not the Docker entrypoint — has ever called it. A database built by the migration chain alone therefore carried **no `(session_id, media_item_id)` constraint at all**. New `migrations/097_playback_state_unique_key.sql` adds it as part of the chain, sorted after `001_initial_schema.sql`, which creates the table. 🔴 **This is the recorded root cause of "a finished episode never leaves Continue Watching."** `PlaybackController::reportProgress()` and `StreamManager::persistStreamState()` persist progress with an `INSERT … ON DUPLICATE KEY UPDATE` whose intended conflict target is that pair; `playback_state` otherwise has only the `id` PRIMARY KEY — a fresh random UUID on every call — so `ON DUPLICATE KEY` could never fire and every ~15 s tick INSERTed a brand-new row. The finish signal wrote a new row instead of updating the in-progress one, so the stale `position_ticks < duration * 0.95` row survived and kept surfacing; Next Up and Most Watched could not be built on a "current" row per pair that was not single-valued; and `playback_state` grew without bound. Production had the key only because somebody ran the finalizer by hand once — which is also why every measurement taken against production looked correct and proved nothing about a fresh install. The migration is safe on a **dirty** database, which is why 090 refused to emit a bare ALTER (1062 on any table that still holds duplicates, retried and failing on every deploy): it checks `information_schema` first (already present → a true no-op, no 1061 note, no table rebuild, and the duplicate scan is skipped because the constraint proves there are none), and if duplicates remain it alters nothing, touches no row, and fails with exactly one error whose text carries the remedy — `Unknown column 'playback_state duplicates: run php migrations/cleanup_090.php' in 'field list'`. The rest of the chain still runs, and the file is left unrecorded so it retries on the next deploy once the duplicates have been merged. 090 and `cleanup_090.php` get comment-only edits, which are checksum-neutral: `MigrationRunner::checksum()` strips full-line comments, so an already-migrated install sees no ledger divergence. `migrations/cleanup_090.php` keeps ownership of **de-duplication only**. Choosing which duplicate survives decides where a user resumes playback, so that rule stays in one place — `PlaybackStateDeduper::findKeeperId()`: greatest `updated_at`, ties broken by greatest `id`, which is deliberately the same ordering `PlaybackController::getContinueWatching()` already picks its row with, so merging is behaviour-preserving with respect to the read path. Its `addUniqueKey()` step stays in place, idempotent, for operators part-way through an upgrade. The read-path `ROW_NUMBER() OVER (PARTITION BY ps.media_item_id ORDER BY ps.updated_at DESC, ps.id DESC)` dedup in `getContinueWatching()` is still required after this migration — one user can have several *sessions* watching the same item, and this key only constrains a single session — but it is no longer being asked to paper over a per-session write bug. ⚠ **A test was green while destroying the constraint.** `PlaybackStateDeduperIntegrationTest` dropped the key in `setUp()` (it has to, to seed hundreds of duplicate groups) and then dropped it *again* in `tearDown()`, so against a 097-built schema it stripped a real constraint off the shared test database and still reported `OK`. It now records whether the key pre-existed and restores it — at the very end of `purgeFixtures()`, after the fixture rows are deleted, because re-adding before the deletes would fail 1062 on the fixture's own duplicates and leave the database unconstrained anyway. - **A fresh install now actually HAS the path-dedupe unique index** (S152). Migration 072 added the `path_hash` generated column but deferred `UNIQUE KEY idx_media_items_library_path_hash (library_id, path_hash)` to the manual `migrations/cleanup_072.php`, and migration 087 then DROPped the index outright. No auto-run path — not `scripts/run-migrations.php`, not `bin/phlix migrate`, not `scripts/install.sh`, not the Docker entrypoint — ever re-created it, so a database built by the migration chain alone carried **no duplicate-path constraint at all**. New `migrations/096_path_hash_unique_index.sql` adds it as part of the chain, sorted after 087 so it cannot fight the DROP. 🔴 **The data-integrity half is the serious one.** That unique key is what makes a duplicate media row impossible and what `ItemRepository::upsertByPath()` catches 1062 on to win a concurrent-insert race. Production was protected only because somebody ran the finalizer by hand once — which is also why every measurement taken against production looked correct and proved nothing about a fresh install. The perf half: without the index the S151 track lookup degrades from `type=const`/`rows=1` back to `type=ref`/`key_len=144` — identical to pre-S151. (Measured on a clean migrations-only MySQL 8.0 database, 400 track rows: `ref`/`idx_library`/`rows=400` before, `const`/`key_len=305`/`rows=1` after.) The migration is safe on a **dirty** database, which is why 072/087 refused to do it: it checks `information_schema` first (already present → a true no-op, no 1061 note, no table rebuild), and if duplicates remain it alters nothing and fails with exactly one error whose text carries the remedy — `Unknown column 'media_items duplicate paths: run php migrations/cleanup_072.php' in 'field list'`. The rest of the chain still runs, and the file is left unrecorded so it retries on the next deploy once the duplicates have been merged. `migrations/cleanup_072.php` keeps ownership of **de-duplication only** — the keeper scoring plus twenty-table reference repointing that lives once in `PathDeduper` and is shared with `media:dedupe-paths`. Its `ADD UNIQUE INDEX` stays in place, idempotent, for operators part-way through an upgrade. ⚠ **`playback_state` has the same defect and is NOT fixed here.** `uq_playback_state_session_media (session_id, media_item_id)` is created *only* by the manual `migrations/cleanup_090.php`; migration 090 carries no executable statement at all. A fresh install therefore still lacks it, and every ~15 s progress tick inserts a new row instead of updating. - **A `HEAD` refused by a global middleware short-circuit no longer receives the refusal body (S295).** `Application::dispatch()` runs the global chain before `Router::markHeadOnly()`, so the `AccessScheduleMiddleware` wrapper's short-circuit reply used to return unflagged and `HttpHandler`'s matched-route branch shipped the 403 envelope as a body on a `HEAD` — the recoverable RFC 9110 §9.3.2 shape, but a keep-alive desync for a header-only client. The reply is now flagged where the global chain returns: the constructor's wrapper routes every global short-circuit through the new `Application::flagHeadShortCircuitReply()` → `Response::asHeadReply()` on a `HEAD` (a `GET` is returned untouched), so all three refusal branches (unprofiled user, unresolvable profile, blocked schedule window) ship head-only with the real `Content-Length`. Proved on a live Workerman worker beside a desyncing control (`AccessScheduleHeadNoBodyWireTest` + `tests/Support/Browser/s295-head-body-server.php`). The denominator is one global middleware — the registration count re-measured at 1 by `ApplicationHeadOnlyBoundaryTest` (count-history line added); route-group middleware was already inside the router's guarantee and no global CSRF middleware exists. No migration (next-free stays 103). - **DLNA advertised far more children than it could deliver on EVERY root, and none of them could be paged** (S147). `LibraryBridge::getLibraryChildCount()` counted with the unbounded `ItemRepository::countAllByType()` while `getLibraryItems()` listed with `getAllByType()`'s default `LIMIT 100`, and `ContentDirectory::browseChildren()` then applied the renderer's `StartingIndex` **in PHP to that already-truncated list** — so any index past the truncation point returned an empty page while `TotalMatches` still advertised the whole container. Measured read-only on production 2026-07-27: Video advertised **10,718** movies and **26,389** episodes against pages of 100 (**107x** and **264x**), and the Audio root — whose advertised count S97 had already clamped to 2,000 to stop it lying — left **2,656 of 4,656 artists unreachable by any navigation**. `StartingIndex` is now resolved in **SQL** on every branch: `getLibraryItems()` takes an offset and spends it against the same per-type counts the container advertises (so a page straddling the movie→series boundary is contiguous); the `music_*` drill-downs and the `parent_id` drill-down (`ItemRepository::findByParentPage()`) take one too; and `browseChildren()` no longer slices — it asks for the requested window and reads `TotalMatches` from a COUNT that shares the listing's predicate. **S97's clamp is removed in the same change**, which is the half that makes this a fix rather than a swap: a renderer told "2,000" stops at 2,000 however good the paging underneath is, so the advertised count is the true unbounded total again and `LibraryBridge::MAX_PAGE_ROWS` is a **page size**, not a ceiling. Ordering across page boundaries is the other half. `getAllByType()` ordered by `sort_title, name` and `findByParent()` by `name` — neither is unique, and with ties MySQL arranges the tied rows differently per `LIMIT`+`OFFSET`. Measured on MySQL 8.0.46 with 400 rows sharing one name, paged 50 at a time: **400 rows returned, only 372 distinct** — 28 silently repeated, 28 never returned at all. Both queries now end their `ORDER BY` on the `id` PRIMARY KEY, making the order total; a real-MySQL test pages a whole container and asserts the concatenated pages equal the unpaged listing row for row. `SortCriteria` is honoured **globally** for any container that fits in one page (`LibraryBridge::MAX_PAGE_ROWS`, 2,000): the whole container is fetched and sorted before the window is taken, so the criteria decides *which* rows the page contains. The first revision of this change sorted only the page it had already fetched, which on a 20-child Video root with `RequestedCount 5` and `-dc:title` returned the five alphabetically **lowest** titles reversed instead of the five highest — a regression against the previous sort-then-slice behaviour, now pinned by a test. Past 2,000 children the sort still applies within the returned page only; making it global there needs the sort key in every listing's `ORDER BY` **and** a merge across a category's types, and is deliberately left out rather than trading pageability for it. One `Browse` now resolves the category counts **once**. Fetching the page and the advertised total went through two entry points that each recomputed them, so a single Video-root browse issued `countAllByType()` six times (`movie, series, video` twice over) — double the COUNTs on an unauthenticated path, and a window in which a row written between the two evaluations made the advertised total disagree with the offsets the listing had already spent. The bridge-less `TotalMatches` is likewise a `countByParent()` now, instead of re-listing the whole container purely to measure it. DLNA remains **off by default and unauthenticated**, so this is a functional gap closed, not a security change. - **A retagged music track could NEVER be re-parented, and `rescan` could not reach the tracks that needed it** (S145). Editing a file's `ALBUM` or `ARTIST` tag left `music_tracks.album_id` / `artist_id` pointing at the *old* album permanently. Two independent halves, both required: 1. **`MusicLibraryScanner::upsertTrack()` could not see, or write, parentage.** Its change predicate compared exactly four fields — `title`, `track_number`, `disc_number`, `duration_secs` — and its `UPDATE` named exactly those four columns. Change only the album or artist tag and all four stay identical, so the call returned `'skipped'` having written nothing. A repo-wide grep finds just three statements that write `music_tracks`, all in this one file, and the other two are INSERTs — so nothing in the codebase could move a track. The columns are `INT UNSIGNED NOT NULL` with enforced foreign keys (migration 065), so the row was never NULL and never violated a constraint: it was **wrong but valid**, which is why nothing ever surfaced it. The `SELECT`, the predicate and the `UPDATE` now all carry `album_id` and `artist_id`, and the album a track *leaves* has its `total_tracks` recomputed (`flushAlbum()`'s `finally` only ever refreshes the album being flushed, and `MusicLibraryService` sums that column onto the artist page). 2. **⚠ `rescan` now RE-READS EVERY FILE for a music library, and that is the fix — today's fast `rescan` is the thing that was wrong.** Fixing `upsertTrack()` alone is cosmetic: the unchanged-file fast path skips a file *before* it is opened, so an already-stamped row never reaches the repair at all. Measured on the production library, **29,134 of 61,111 tracks (47.7 %, rising)** would be skipped by an ordinary scan. `LibraryManager::rescanLibrary()` therefore asks the music scanner to leave its skip index unloaded. Migration 084 has documented `rescan` as the heavy option since it was written — this restores a promise the schema already made rather than inventing a new one, so there is **no migration, no new job type and no new command**: the admin *Rescan* action and `php bin/phlix library:scan <id> --rescan` both get it for free. **Expect a music rescan to take hours, not minutes** (the last completed one took **9 h 55 m** for 61,111 tracks — an earlier note here said "~3.5 h", which was an estimate presented as a measurement; S151 attacks the dominant cost but its effect on that wall clock is not yet measured), and to report `updated` in the thousands on its first run — that is the repair, not an error. It is interruptible and idempotent; re-running continues. The scan summary gains `reparented` (how many tracks moved) and `read_every_file` (whether the scan really was a full read). **Use `scan`, not `rescan`, for an incremental refresh** — the incremental path is unchanged and still skips unchanged files. Measured impact on production 2026-07-27: **310 albums owning zero tracks** plus 7 empty artists; **292 tracks provably mis-parented** (a floor — the rest cannot be identified by SQL, because the truth is in the file's tags and the tags are not in the database). It was accumulating, not dormant: of the albums created once the initial import settled, **100 % (07-26) and 97.8 % (07-27) were empty shells**, i.e. essentially every new album the scanner created was one whose tracks it then refused to move. Not done here, deliberately: the now-vacated shell albums are **not** deleted. `fk_tracks_album` is `ON DELETE CASCADE`, so reaping them is a destructive decision with artwork behind it and is a follow-up step of its own. - **`media_item_id` was silently ALWAYS dropped on all three music DTOs** (S121). `MusicArtist::fromRow()`, `MusicAlbum::fromRow()` and `MusicTrack::fromRow()` each coerced `media_item_id` through `is_numeric($row['media_item_id']) ? (int)… : <fallback>`, while the column is a **`CHAR(36)` UUID** in all three tables. `is_numeric()` is false for every UUID, so the field was the fallback no matter what the row held: **`null`** on artist/album, **`0`** on track. The coercion matches the columns' *pre-070* shape — they were `INT UNSIGNED` when the music DTOs landed (2026-07-09) and migration **070** converted all three to `CHAR(36)` the next day, because `media_items.id` is a UUID — and it was never updated with them. Confirmed against real MySQL 8.0 rather than inferred: `information_schema` reads `music_artists.media_item_id char(36) YES`, `music_albums char(36) YES`, `music_tracks char(36) NO`, and `workerman/mysql` hands the value back as a PHP `string`. All three DTOs now declare `public ?string $mediaItemId` and coerce through a private `mediaItemIdFromRow(): ?string`: - a **non-string** (`int`, `float`, `bool`) or the **empty string** becomes `null`; - **any other non-empty string passes through unchanged** — including `'0'` and a truncated UUID. Format validation is deliberately not the DTO's job; the FK to `media_items` is. - **`?string` on the NOT NULL `music_tracks.media_item_id` is deliberate.** `fromRow()` never throws — it is also fed hand-built, cached and JSON rows, not just real `SELECT`s — so an absent column must still produce a value, and `null` is the only one that cannot be mistaken for an id. The old `0` fallback could be: `0` is a plausible-looking id that survives the `!== null` / `?? …` / `is_string()` tests callers actually write, which is exactly how a dropped field stayed invisible. So `null` on a track means "the SELECT did not carry the column", not "no artwork yet" — the latter is what it legitimately means on artist/album, whose columns are NULLable and which the scanner writes as NULL when its `createMediaItem()` mint fails, backfilling on a later pass. - The sibling `id` / `artist_id` / `album_id` / `year` / `total_tracks` / `total_discs` / `track_number` / `disc_number` / `duration_secs` columns really are `int unsigned` and **keep** their `is_numeric()` guards. **No served payload changed** — re-verified, not assumed. `MusicController` formats artists through `formatArtist()` (`name`/`image_url`/`album_count`/`track_count`/`albums_truncated`/`albums`), and the one route that could have nested a DTO's `toArray()` — `MusicArtistWithAlbums::toArray()`, which nests `MusicArtist::toArray()` and so would emit the field — has had no caller in `src/` since S99 removed the music GET handlers. So this closes a latent trap on a **live** read path — `getAllArtists()` builds `MusicArtist::fromRow()` off `SELECT a.*` on every `GET /api/v1/music/artists` — without widening any response. The stale note in `MusicLibraryService`'s class docblock, which had described this exact bug for **`MusicTrack` only** since S99, is replaced by a "fixed in S121" block that states the asymmetric `null`-vs-`0` fallback correctly. **Guarded mechanically, because a hand-written list is what created the bug** — the defect was written down for `MusicTrack` alone and both siblings were missed. `tests/Unit/Media/Music/MusicDtoMediaItemIdTest.php` therefore names no DTO: it walks `src/Media/Music/` **recursively**, derives each file's PSR-4 FQCN, and reflects **every** class that declares a `mediaItemId` property, asserting the declared type is `?string` — so a fourth music DTO landing with the old `is_numeric()`/`int` coercion goes red on its own, and the accompanying set-equality test forces these docblocks to be updated with it. The walk **fails loudly instead of skipping**, in two shapes: a file it cannot load raises a named `RuntimeException` quoting the unresolved class and its path plus the usual causes, and two files resolving to **one** class identity (filenames differing only in case, a `class_alias()`, a namespace that does not match the directory, a symlink) raise one naming **both** colliding paths and inferring the cause from them rather than asserting it. Either surfaces as a PHPUnit error with a non-zero exit — a silently skipped file would be precisely the partial sweep this step exists to remove. Two companions pin the behaviour itself: `MusicDtoFromRowCoercionTest` covers the value space the predicate has to survive (non-strings → `null`; `'0'` and `'123'` unchanged, asserted with `assertSame` so neither an `!empty()` rewrite nor a re-added `(int)` cast survives; plus `parseDateTime()`'s four outcomes), and `MusicDtoMediaItemIdIntegrationTest` reads a minted UUID back out of all three tables against real MySQL in **both** pooled and unpooled connection modes. 62 tests across the three files. **Not fixed, documented instead** (each recorded in the DTO docblocks): - **Two of the five copies of this coercion predicate are pinned by no test.** The same predicate is inlined as a *local variable* in `MusicLibraryScanner`'s `upsertArtist()` and `upsertAlbum()` — `grep -rn "media_item_id'\] !== ''" src/` finds exactly those two and none of the DTOs — and a guard that reflects *properties* structurally cannot see a local. Filed as **S127**; deferred rather than bodged because S96 rewrites that file, so any source-text guard written now would be rework. - **Whitespace is not normalised.** There is no `trim()`, so `' '` returns `' '` and a padded UUID keeps its padding; the two mutants that would add one leave both unit test files green. It is the one input where the "make 'no id' detectable" goal fails, and it is disclosed rather than closed because the driver cannot produce it — MySQL strips a `CHAR` value's trailing pad spaces, so an all-space column reads back as `''`. - **`parseDateTime()` still fabricates a timestamp from `''`** (it returns *now*) and returns `null` for a `DateTimeImmutable`, because its guard is `instanceof \DateTime`. Pre-existing, byte-untouched by this change, filed as **S143**. - **The TypeScript contract still disagrees.** `phlix-contracts`' `src/Music.ts` types `mediaItemId` as a `number`. No client reads the field today; filed as **S123** rather than edited from a `phlix-server` branch. - **An auth-provider settings save no longer WIPES the keys it did not send** (updates.md #54 / S48). A provider settings save is a wholesale replace of the stored document, and both admin controllers built that document from the request body alone — so a client that did not know about a field silently deleted it. This is the exact shape that **wiped live Trakt OAuth tokens on production** during a plugin update, and it was live, not theoretical: the admin SPA's OIDC form posts `provider_url`/`client_id`/`client_secret`/`scopes` and knows nothing about `redirect_uri`, so one click on Save would have erased a `redirect_uri` configured through the API — which, with the change above, hard-`503`s every OIDC login. The invariant is now explicit in `GithubAdminController` and `OidcAdminController`, for every optional key (`scopes`, `redirect_uri`): **a key ABSENT from the request body is PRESERVED; only an explicitly empty value clears it.** Absent is never a deletion. (`client_secret` already behaved this way — blank keeps the stored secret.) A rejected save (`invalid_redirect_uri`, `missing_client_id`, …) mutates nothing, and repeated partial saves do not erode the row. Guarded by `AuthProviderSettingsPreservationRealDbIntegrationTest`, which drives both real controllers through the real repository against real MySQL and reads `plugin_settings.settings_json` straight back out of the table; reverting either key on either controller to a wholesale replace fails it with a message naming the incident. **Not fixed, documented instead:** `LdapAdminController::saveSettings()` still rebuilds its document from the request body alone (only `bind_pw` is kept on blank), so a *scripted* partial save resets an omitted `port`/`ssl`/`bind_dn`/ `user_filter`/`admin_group` to its default. The admin console's LDAP form always posts the complete set, so no shipped client is affected; API callers must send the full map. Noted in the class docblock and in the SSO guide. - **Every episode / track / photo play threw an uncaught `PDOException` in the HTTP worker** (S102). `PlaybackController::dispatchPlaybackStarted()` passes the RAW `media_items.type` value into `StatsCollector::recordPlaybackStart()` — correct, and the entire point of S31 — but migration 019 had declared `stats_playback_events.media_type` as `ENUM('movie','series','music','photo')`: **four** of the column's **thirteen** members. Under `STRICT_TRANS_TABLES` every other value is MySQL error **1265 "Data truncated for column 'media_type'"**, which the driver raises as a `PDOException` that nothing on the path caught. Production corroborates exactly that: **all 235** stored rows were `movie`, and every non-movie play since S31 threw instead of recording. - **Migration 094 widens the column to the full 13-member vocabulary**, in `media_items.type` column order so both share one ordinal numbering. Widening (rather than folding the 13 types down to 4) is safe *and* correct here because the column has **no readers** — every query over `stats_playback_events` (`getPlaybackStats`/`getTopUsers`/`getTopMedia`, `NewsletterGenerator`, `DashboardService::getRecentPlaybackEvents`) aggregates by date / user / media item and never selects or groups by `media_type` — so no consumer's output shape changes, while folding would have permanently destroyed the per-type resolution S31 exists to capture (`episode`→`series`, `track`→`music`, `audiobook`→`book` are all one-way). The new member list is a strict superset of the old one, so no stored value can be truncated; verified against real MySQL 8.0 with 235 seeded `movie` rows. - **`stats_storage.media_type` deliberately stays coarse** and is fixed the *other* way. It is the one such column with a real reader — `DashboardService::getStorageSummary()` groups by it into a fixed `{movie,series,music,photo,book}_bytes` shape — so widening it would have dropped bytes into that `match`'s `default` arm and quietly broken the admin dashboard. Instead the snapshot writer folds whatever it is handed through `StorageSnapshotHelper::TYPE_TO_BUCKET` (exhaustive, and idempotent because every bucket maps to itself), so a caller passing a raw `episode` gets a correct `series` row rather than error 1265. Both existing callers already passed bucket names and are unaffected. - **Statistics can no longer break playback at all.** Every `StatsCollector` *write* now runs behind a containment boundary that logs the failure at `error` level (so it lands in `.logs/error-YYYY-MM-DD.log` — the `error` handler is a `rotating_file`, so the configured `error.log` path is date-stamped on disk) and swallows it — telemetry is strictly less important than the action that triggered it, and `dispatchPlaybackStarted()` has no try/catch of its own. Repeated failures are counted per operation and the log is throttled to one line per operation **per exception class** per minute, with `failures_total` / `suppressed_since_last_log` / `operation_failures_total` in the context and the running totals (still keyed per operation) readable via `StatsCollector::writeFailureCounters()`. The exception class is part of the window key because keying on the operation alone suppressed a *new kind* of failure for an already-failing operation: once `library_change` had logged a `RuntimeException`, a `PDOException "MySQL server has gone away"` in the same 60 s window was counted but its message never reached the log. A repeat is noise; a different class is news. `ItemRepository::recordChange()` records one library change per scanned item, so an un-throttled boundary would have written ~29,000 identical error lines during one music scan of the production library. *Reads* are deliberately left unguarded: they serve the admin dashboard, where a broken query must surface as a visible error rather than a silently empty chart. - **The dashboard's five storage roll-ups now equal the bytes that were written.** Folding raw types onto five buckets means several types share a bucket, and every row of one snapshot run carries the same `NOW()` second — so `getStorageSummary()`'s `MAX(recorded_at)` join legitimately returns *several* rows per bucket (also one per `library_id`). It used to **assign** (`=`) each bucket's bytes while ordering by `total_bytes DESC`, so the smallest colliding row won and the rest disappeared: 13 folded types worth 91,000 bytes produced five totals summing to **31,000**. Both sides are now additive — `SUM(…) GROUP BY media_type` in SQL and `+=` in PHP — and a snapshot run is written as ONE batch (`StatsCollector::recordStorageSnapshots()`), which sums per bucket before inserting so a run cannot produce two rows for the same bucket. Verified on real MySQL: 13 types / 91,000 bytes in, 91,000 across the five roll-ups out. `StorageSnapshotHelper::bootstrapSnapshot()` also finally honours its documented "if data is stale or missing" contract (it was called on *every* PHP-FPM request, re-running `du -sb` over both vault roots each time), because summing same-second rows would otherwise double the totals when two snapshot runs land in one second. That staleness check compares the age by ABSOLUTE distance, so a newest row dated in the *future* (a clock that stepped backwards, or one stray row) reads as stale rather than as "fresh forever" — measured pre-fix: one row dated +1 day and the fallback never refreshed again. - **A snapshot run now carries ONE `recorded_at`, so a looping caller cannot lose bytes to a wall-clock second boundary.** `recorded_at` is a second-precision `DATETIME` and the dashboard joins on `MAX(recorded_at)` *per `media_type`*, so a run whose rows straddle two seconds silently loses every bucket that received rows in more than one of them. With a fresh `NOW()` per INSERT that was decided by luck: measured on a loaded box, 13 `recordStorageSnapshot()` calls spread over three seconds and the dashboard reported **44,000 of the 91,000 bytes handed in**. The run's second is now computed once and bound through `FROM_UNIXTIME(?)` — a bound unix second rather than a PHP-formatted string, so the value is exactly what `NOW()` would have produced in the MySQL session's own time zone. What ends a run is a *stall* between two snapshot writes — a gap of **5 s or more** (the comparison is `<`, so exactly 5.000000000 s already starts a new generation) — and not an elapsed duration, so a run cannot be split into generations by its own length: 13 calls a full second apart (13 s end to end) round-trip all 91,000 bytes and 13 of 13 item counts, where the same run on the old write path reported 48,000 and 5. The batch form was already immune by accident (one row per bucket makes the per-type `MAX` a no-op); the guarantee is now structural for both forms, pinned by an integration test that crosses a second boundary on purpose and stamps nothing, and — since a stall boundary that no test could see would be deleted by the next cleanup — by a unit test that discriminates "continued" from "recomputed" with a sentinel stamp and brackets the window at 4.9 s / 5.0 s with no database. Note what the gap window does *not* buy: it cannot tell a stalled run from a busy one, because all it sees is the interval between two *writes*. For a hypothetical per-library writer that interval **is** the next library's `du -sb` (measured warm on the dev box at 151–257 k inodes/s depending on load, so 5 s buys only ~0.75–1.3 M inodes — and a six-hourly snapshot always meets a *cold* cache), so such a writer must hand its whole run to one `recordStorageSnapshots()` call — which is exactly what both current callers do, taking the stamp once before the write loop, so the window is never consulted between one run's own rows. - **Each half of the reader's aggregation is pinned on its own.** `SUM(…) GROUP BY media_type` and the PHP `+=` arms hide each other — the `GROUP BY` collapses the result set so `+=` never sees a second row, and with `+=` present the `SUM` is invisible — so the whole suite stayed green with *either* reverted, and only the simultaneous revert failed. That matters because per-library snapshots will legitimately produce several rows per bucket and "the SQL already groups" is a plausible reason to simplify the arms back to `=`, which would render three 1 TB libraries as 1 TB. The `+=` half now has a database-free test (two `movie` rows, 1,000 + 2,000, must be 3,000) and the SQL half a real-MySQL one (two rows per bucket in one second must come back as five `items` rows). - **An unrecognised media type is now dropped from the storage snapshot, loudly.** It used to fall back to `movie`, i.e. unknown bytes were added to a real bucket with only a warning in `app.log`. `migrations/086_stats_storage_book_bucket.sql` already wrote the rule down — *"a wrong number that looks right is worse than a visibly missing one"* — so the writer now logs at `error` and writes no row. `MediaItemType::FALLBACK` remains correct for *labels* (`normalize()`, `shape()`, `lookupMediaType()`) and is no longer used for a byte accumulator. - **Root cause was a duplicated type vocabulary, so that is what got fixed.** The 13-member list had been re-typed by hand in four places, each carrying a comment asking the next author to keep it "in lockstep" — a convention that had already failed three times (this bug; `book`/`audiobook` bytes missing from the dashboard, migration 086; `track` missing from the Music totals). New `Phlix\Media\MediaItemType` holds the list **once**, `MediaItemShaper::VALID_TYPES` is now an alias of it, and a new drift test **parses the migration SQL** and fails the build when the constant, any consumer map (`TYPE_TO_BUCKET`, `UpnpClassMap::TYPE_TO_CLASS`, `VALID_TYPES`) or any of the type-carrying columns disagree. The previous cross-checks compared the PHP copies only to each other, which is why all of them could agree while the actual column did not. The parser reads `CREATE TABLE` column definitions and `ALTER TABLE … MODIFY [COLUMN]` / `CHANGE [COLUMN] <old> <new>` / `ADD [COLUMN]` clauses — backticked or bare, schema-qualified or not, in any position of a multi-clause `ALTER`, any case, any whitespace — i.e. every style this repo's migrations actually use, each pinned by its own test case. `ADD [COLUMN]` is included because it was the one style that could redefine a tracked column while leaving the alarm green (as `DROP COLUMN` + `ADD COLUMN`), and because `users.status` — defined that way by `migrations/037` — was the single ENUM column of the 29 in a fully migrated schema the parser could not read. Three blind spots are deliberate and now documented on `MediaItemType`: DDL assembled at runtime inside a `PREPARE`/`EXECUTE` string (as `migrations/011` does) is **skipped** rather than half-parsed, so a widening must be written as a plain statement to be seen (parsing it naively yielded 24 empty-string "members" that the first version of the guard accepted as success); a bare `DROP COLUMN` with no re-`ADD` cannot widen an ENUM; and `migrations/*.php` files are not read at all — which is safe because `MigrationRunner` likewise globs `*.sql` only, and a test now reddens if any `.php` migration ever contains an `ENUM(` definition. - Proven against real MySQL 8.0 under `STRICT_TRANS_TABLES` (a mocked connection accepts any string for any column and cannot reproduce an ENUM rejection — the same class of miss as the LiveTv `RowQuery` and metrics `ONLY_FULL_GROUP_BY` defects): all 13 members round-trip verbatim, an unknown playback type is coerced instead of rejected, an unknown storage type writes no row at all, a failing write is contained, the storage roll-ups equal the bytes written, and a first progress report on an `episode` records `media_type=episode` without throwing. - **A `HEAD` on a media route put TWO conflicting `Content-Length` headers on the wire** (updates.md #44 / S52 review). Workerman's response encoder (`Protocols/Http/Response::__toString()`) appends its own `Content-Length: strlen($body)` **unconditionally** and **last**, so a handler that correctly set the real size on a bodyless `HEAD` reply emitted `Content-Length: <size>` followed by `Content-Length: 0`. RFC 9110 §8.6 makes such a message invalid — recipients must reject it, hardened proxies (HAProxy) drop it as a request-smuggling defence, and clients disagree about which value wins. DLNA renderers probe a resource with `HEAD` *before* they open it, so the reply meant to advertise the size was the one that broke. New `Phlix\Server\Workerman\BodylessResponse` narrows `__toString()` to leave a caller-supplied `Content-Length` alone on an empty body (and delegates to Workerman untouched for everything else). It is reached two different ways, because the two affected routes are dispatched by different code: `Response::toWorkermanResponse()` selects it for `HEAD` replies **only** — never for a GET that merely came out empty, whose stale length would be a keep-alive framing desync rather than a fix — which is what fixes `HEAD /dlna/stream/{id}`; the pre-existing twin on `HEAD /media/{id}/stream` is fixed by **naming the class directly** in `HttpHandler::serveMediaStream()`, which runs *before* `Application::dispatch()` and returns Workerman responses, so it never builds a Phlix `Response` and has no `headOnly` flag to select on. Every other reply, 204/304/redirect/416 included, is byte-identical to before. Pinned by assertions on the **encoded bytes**, with the expected bytes *derived from* Workerman's own encoder so a future dependency bump cannot make the narrowed copy diverge silently — the previous tests inspected the header *array*, which cannot observe this defect at all. - **A route registered for `HEAD` in its own right relied on its handler remembering to flag the reply** (S52 post-merge review, finding 1). `Response::$headOnly` is what selects the narrowed encoder above, and it was set **only** by `Router::dispatchAsHead()` — the GET→HEAD *fallback*. A route registered as `match(['GET', 'HEAD'], …)` (which is how the DLNA stream route is registered, so that a file-backed body is not suppressed to `Content-Length: 0`) never reaches that method, so the router set nothing and correctness depended on each controller setting the flag by hand. The one such route today does, but the next one to declare a `Content-Length` on its HEAD arm and forget the flag would have shipped `Content-Length: 123456789` followed by `Content-Length: 0` again with the whole suite green. `Router::dispatch()` now flags **every** response it returns for a `HEAD` request (`markHeadOnly()`, the single writer of the flag in the class: both route maps, both middleware short-circuits, and the fallback), so the framing is a property of the framework rather than a convention. It cannot flag a GET — the method test is the whole body — and it is idempotent, so the DLNA controller keeps its own explicit set as defence in depth for callers that are not `Router::dispatch()`. **One live reply does change on the wire, for the better:** the DLNA stream route is registered inside a group carrying `DlnaAllowlistMiddleware`, and that gate's refusal is now flagged too, so an off-LAN `HEAD /dlna/stream/{id}` answers `403 … Content-Length: 0` with **no** body (101 B) where it previously shipped its 104-byte `{"code":"dlna.forbidden"}` JSON alongside `Content-Length: 104` (207 B). RFC 9110 §9.3.2 forbids content in a `HEAD` response, so the new bytes are the conforming ones — but a `HEAD` probe can no longer read *why* it was refused (use a `GET`), and the `Content-Length: 0` under-reports what the equivalent `GET` would return, exactly as the pre-existing `withFile()`-on-a-HEAD case does. The HEAD-registered stream route's own 200 reply is unchanged (its controller already set the flag), as is every 204/304/redirect/416. Pinned by `RouterTest` tests that register a HEAD route which does **not** set the flag and assert on the **encoded bytes** that exactly one `Content-Length` reaches the wire, with a control showing the framework encoder emits two for the same shape — including all three arms r1 found unpinned (`dispatch()`'s static short-circuit and both halves of the parametric GET→HEAD fallback, the live path for `/api/v1/media/{id}`, `/hls/{job}/{seg}` and `/stream/theme-media/{libraryId}/audio`, where an unflagged reply measured 4,377 B with two lengths against 113 B with one). **Bounded, not covered:** a *global* middleware registered on `Application` runs before the router, so a `HEAD` it short-circuits is never flagged and still ships its body. Today only `AccessScheduleMiddleware` can do that and its refusals declare no `Content-Length` of their own, so the reply is the recoverable "body on a HEAD" shape rather than the unrecoverable two-length one — the same shape as `Router::notFound()` and `HttpHandler`'s SPA/static/404 replies, all of which move together in a separate change. `ApplicationHeadOnlyBoundaryTest` pins that boundary from both sides so it cannot drift, and `Application::dispatch()`'s docblock states it. - **`Router::group()` could leak its middleware onto every later route** if the group callback threw. The prefix/middleware restore had no `finally`, and `addRoute()` copies the current group middleware onto every route registered afterwards — so one throw inside `loadCdsRoutes()` would have silently attached the DLNA **IP allowlist** to the ~15 route loaders that run after it, and those endpoints would have begun refusing every non-LAN caller. Low probability, wide blast radius; the restore is now unconditional and the log call inside that catch is itself guarded. Pinned by tests that register a throwing group (flat and nested) and assert both that a route registered afterwards carries no leaked middleware and that a nested group restores the *outer* prefix and middleware rather than clearing them. - **"Original" quality was silently unavailable for every HEVC / non-AAC title** (updates.md #30 / S49). The v7 ABR ladder FOLDED the `original` rendition out of `LadderResult::streamVariants()` whenever a re-encoded (non-copy) Original was the same frame at effectively the same BANDWIDTH as the top ladder rung — which is the normal outcome for a low-bitrate source, since the ladder caps every rung at the source bitrate. `TranscodeManager::writeVodPlaylists()` iterates exactly that list, so a folded Original never got a media playlist written at all: `media_voriginal.m3u8` simply did not exist, and "Original" was either missing from the quality menu or a 404 (masked only by a serve-time alias that quietly served the top rung's playlist instead). Every job now always publishes a real Original variant: - `LadderResult::streamVariants()` always returns `[original, ...renditions]`; the fold is gone from the variant list. - `TranscodeManager::getJobVariants()` no longer mirrors that fold (its private `originalDuplicatesTopRung()` helper is removed), so the client `variants[]` payload always advertises `{id: 'original', …}` with its own media-playlist url. - The duplicate-BANDWIDTH problem the fold existed to prevent is now solved where it belongs — in the master playlist. `writeVodPlaylists()`'s SV-4.6 switchable filter (`TranscodeManager::switchableVariants()`, still using `Rendition::duplicatesForAbr()`) withholds a variant from the ABR-switchable `#EXT-X-STREAM-INF` set in exactly two cases: a stream-COPY variant, always (its segment boundaries can drift off the uniform timeline), and a TRANSCODE `original` that duplicates the top rung's frame + BANDWIDTH, which is the low-bitrate collapse a player would merge into one level. Both still get their own media playlist, which is what makes "Original" explicitly selectable. **The master's advertised level set is therefore unchanged from v8**: a transcode `original` that is NOT such a duplicate stays the master's top level, exactly as before — including the large class of sources whose original height matches no canonical rung (2.39:1 crops, DCI-2K). An interim revision of this change excluded every `original` from the switchable set; that dropped the master's top level for those sources (e.g. a 1920×1080 HEVC @8 Mbps master went from `original` at 10.0 Mbps down to `1080p` at 5.478 Mbps, halving the auto-ABR ceiling) and is NOT what shipped. Four tests now pin the full level set — ids + BANDWIDTH + RESOLUTION — per source shape (copy / distinct transcode / anamorphic / duplicate). - `TranscodeManager::ensurePlaylistRegenerated()` rebuilt its variant list from the persisted `renditions` **only**, so an LRU cache eviction destroyed `media_voriginal.m3u8` permanently — even for a never-folded stream-COPY Original that had been written correctly. It now reproduces the same `original`-first set `ensureHlsJob()` wrote. The same method also read audio tracks from a `transcode_jobs.audio_tracks` column that has never existed in any migration (`JOB_ROW_COLUMNS` cannot select it), so multi-audio jobs regenerated with no `#EXT-X-MEDIA` audio group and no `media_a{N}.m3u8` playlists; they are read from the persisted ladder JSON now. - `TranscodeManager::ensurePlaylistRegenerated()` also now validates each persisted rendition id against the same `^[a-z0-9]+$` allowlist the segment serve path uses (the id is interpolated into `media_v{id}.m3u8` and into the master's URI), and returns `false` instead of writing a master with zero `#EXT-X-STREAM-INF` levels when nothing in a corrupt ladder survives that check — such a file would otherwise short-circuit every later regeneration attempt and permanently break the job. - `JOB_KEY_VERSION` **v8 → v9** so pre-existing jobs (whose directories lack the Original playlist) are not reused and age out via the cache sweep. `HlsController`'s folded-original serve-time alias is retained for pre-v9 job directories still on disk (bounded by the cache sweep's idle TTL, 3 h by default) so already-issued signed URLs keep playing; its removal is tracked as part of **S59**. - **No client change was required, because the master's advertised level set is unchanged** (see above): `quality.ts`/`QualityMenu.vue` resolve "Original" by matching a master level of the same height and then load `media_voriginal.m3u8` directly, which is precisely the file that now exists. That client gate does have a pre-existing blind spot this change neither introduces nor fixes — a stream-COPY Original is (as before) never a master level, so when its height matches no level and exceeds them all (e.g. a 1920×800 H.264/AAC source against a 720p top rung) the menu still hides "Original" even though the playlist is now guaranteed to exist. Hardening that gate to key off the server's `variants[]` entry rather than level height-matching is tracked as a `phlix-ui` follow-up. - **External identities were stored with a hardcoded `provider='external'`** (updates.md #37 / S44). `UserRepository::findOrCreateByExternalId()` ignored which provider actually authenticated, so an OIDC `sub` and an LDAP DN could collide onto the same account. It now takes a `$provider` argument (threaded from the `AuthResult`), scopes the lookup by `(provider, external_id)`, and stores the real provider (`oidc` / `ldap`) — two providers presenting the same identifier map to distinct users. This is the foundation S45–S47 (account-linking / multi-identity) build on. - **First OIDC/LDAP login 500'd because `users.password_hash` was `NOT NULL`** (updates.md #37 / S44). External (passwordless) users are created with `password_hash = NULL`, but the column had never been relaxed since migration 001. New migration `091_users_password_hash_nullable.sql` (`ALTER TABLE users MODIFY password_hash VARCHAR(255) NULL`) fixes it; it applies automatically via the migration runner on upgrade (no manual step). External users with no email/username from the IdP are still created — they get a deterministic placeholder derived from `(provider, external_id)` so two email-less accounts can't collide on the unique `email` / `username` columns. - **Relay tunnel forced TLS regardless of scheme → silent hang against a plaintext hub relay port** (updates.md #38 / S38). `openHubConnection()` previously set `transport='ssl'` and attached an SSL context unconditionally, and `RelayConfig` derived a `wss://` relay URL for any `https://` hub — so a default deploy tried to do a TLS handshake against the hub's plaintext relay listener (`:8802`), which produced no `onError`/`onClose` and simply deadlocked. The transport now keys off the *resolved URL scheme*: a new pure `RelayConsumer::resolveHubTransport()` helper derives `useTls = (scheme === 'wss')`, rewrites `wss://` → `ws://` for the Workerman address (wss is not a registered transport), and only `openHubConnection()` when `useTls` sets `transport='ssl'` + the SSL context. **The default derived relay scheme is now plaintext `ws://`**, matching the hub's plaintext-by-default relay listener — a change from the old always-`wss://` behavior. A TLS relay tunnel now requires `PHLIX_RELAY_TLS=1` on the server together with `HUB_RELAY_TLS=true` on the hub (see the new keys under Added). At boot, if the resolved relay URL is `wss://` while `relay_tls` is off, a clear once-per-process `logger.hub` warning explains the likely hang and the envs to set. - **Admin Relay Tunnel Enable/Disable/Ping/Status now reflect the REAL tunnel instead of a never-started in-worker object** (updates.md #39 / S39). The four `AdminHubController` relay endpoints (`GET /api/v1/admin/remote/relay/status`, `POST …/relay/enable`, `…/relay/disable`, `…/relay/ping`) were operating on a container-local `RelayConsumer`/`RelayApplication` copy that never runs (the real tunnel is the forked `phlix-relay-tunnel` process) and probed liveness with a blocking `exec('pgrep -f phlix-relay-tunnel')` + log-scrape inside the event loop. That blocking subprocess I/O is **removed entirely** (the endpoints now read the cross-process state files `RelayStateStore` maintains — `relay-tunnel.state.json`, `hub-heartbeat.state.json`, `relay-control.json`), and the fake `{"success":true}` no-ops are gone: - **Enable/Disable are an honest, persisted kill-switch, effective on next server reload** — not an instant in-process start/stop. Disable writes `disabled:true` to `relay-control.json` (which the relay fork honors at boot in addition to the `PHLIX_RELAY_DISABLED` env var); Enable clears it. Both return `{success, disabled, enrolled, takesEffectOnReload:true, message}`. Enable **cannot unset the `PHLIX_RELAY_DISABLED` env var**, so when the env kill-switch is set the response stays `disabled:true` with an honest message; a persist failure returns `500`. - **Status** returns the real persisted `connected`/`active`/`enrolled` plus `reconnectAttempts`, `activeSessions`, `lastDisconnectTime`, the last `lastConnectError`/`lastConnectErrorAt` reason, the effective `disabled` kill-switch, and `updatedAt` (fork-write staleness signal). Back-compat keys `endpoint`/`establishedAt` are retained for the current UI. - **Ping** reports the persisted latency: `{success, connected, active, latencyMs, lastHeartbeatAt, latencySource:"persisted"}`, with `latencyMs:null` = "not measured yet" (never a fabricated timing), and returns **`409`** (with `lastConnectError`/`lastConnectErrorAt`) when the tunnel is not connected rather than pretending to ping. Kill-switch single-writer discipline is preserved: `relay-control.json` is written only by the HTTP worker, read by the relay fork at boot; the fork writes the two state files, the HTTP worker only reads them. Consumed by the reframed `@phlix/ui` admin Remote Access panel (v0.98.29). - **Network Health now reflects real cross-process tunnel/heartbeat state; the `/health/network` probe no longer side-effects a heartbeat** (updates.md #40 / S40). `Admin\HealthController::relayHealth()` (`GET /api/v1/health/relay`) and `networkHealth()` (`GET /api/v1/health/network`) previously read a never-started, container-local `RelayConsumer`/`HubClient` copy — so the admin Network Health panel always showed offline/0/null even on a healthy, enrolled, connected box. Both endpoints now read the persisted cross-process snapshots via `RelayStateStore` (`relay-tunnel.state.json` + `hub-heartbeat.state.json`, written by the `phlix-relay-tunnel` and `phlix-hub-heartbeat` forks); `relayHealth` adds additive `relay.lastConnectError`/`lastConnectErrorAt` and `hub.lastLatencyMs` fields, and the heartbeat fork now records real round-trip latency each tick via monotonic `hrtime(true)` in `HubClient::performHeartbeatTick()` (lighting up the S39 Ping latency). Critically, **`/health/network` is now a cheap, side-effect-free reachability probe**: the previous implementation fired a REAL `POST /api/v1/servers/{id}/heartbeat` to the hub on **every** poll of the admin network-health indicator (mutating hub state and hammering the hub as the poller ran); it now just reads the latency/health snapshot the heartbeat fork already persists. Status classification is unchanged (`healthy` <100ms, `degraded` ≤500ms, otherwise `offline`; `offline` when not enrolled, no successful heartbeat yet, or the heartbeat is currently failing). Single-writer discipline preserved — only the heartbeat fork arms `HubClient::startHeartbeatLoop()` and writes `hub-heartbeat.state.json`. Known follow-up: no `updatedAt`-staleness guard, so a stale "healthy" snapshot could persist if the heartbeat fork itself hangs. - **`CollectionService` empty-TMDB-key smell** (S33). `getOrCreateCollection()` and `syncCollectionForMovie()` took a `$tmdbApiKey` argument the bodies never used — the scanner passed an empty string (`syncCollectionForMovie($id, '')`). The TMDB key is actually resolved by the injected `TmdbProvider` (constructed with the configured key), so the dead parameter is removed from both methods. `syncCollectionForMovie()` now also skips cleanly (no HTTP, no DB writes, returns a no-op success) when `TmdbProvider::hasApiKey()` is false, instead of issuing requests that would just fail. TMDB calls continue to flow through the async `MetadataHttpClient` — never a new inline/blocking call in the scan path. - **Playback stats now record each item's real `media_items.type` instead of hardcoding `movie`** (updates.md #31 / S31). `PlaybackController::dispatchPlaybackStarted()` previously wrote a literal `'movie'` type for every playback-start stats event, so Most Watched / Top Media and every other type-partitioned stat mis-attributed episodes, tracks, etc. as movies. A new private `lookupMediaType()` runs `SELECT type FROM media_items WHERE id = ? LIMIT 1` and records the raw stored ENUM value verbatim (no remap — `photo` stays `photo`, honoring the 13-member type ENUM), falling back to `'movie'` only when the row is missing/empty (the same default `MediaItemShaper::shape()` coerces unknown types to). The lookup runs only on the stats path (`statsCollector !== null && userId !== ''`), so a non-stats deployment pays no extra query. - **`playback_state` progress upserts now update in place instead of inserting a new row every ~15s** (updates.md #29 / S29). Every progress tick from `PlaybackController::reportProgress()` (and `StreamManager::persistStreamState()`) writes via `INSERT ... ON DUPLICATE KEY UPDATE`, but the `playback_state` table only carried its `id` PRIMARY KEY — a fresh random UUID per call — and had no key on the upsert's real conflict target, so `ON DUPLICATE KEY` could never fire and each ~15s tick inserted a brand-new row, bloating the table and undermining resume / Continue Watching. Adds `UNIQUE KEY uq_playback_state_session_media (session_id, media_item_id)` so the existing upserts finally update the matching row. No controller change was needed — both upserts already INSERT `(id, session_id, media_item_id, ...)` and touch only `position_ticks`/`duration_ticks`/ `playback_status`/`updated_at` in their `ON DUPLICATE KEY UPDATE`, i.e. they already target exactly the new key's columns. Mirrors the migration-072 `path_hash` dedupe-then-constrain precedent: `migrations/090_playback_state_session_media_unique.sql` is documentation-only (it splits to zero executable statements, reserving the number in the ledger), and the unique key is added out-of-band by the new one-time `migrations/cleanup_090.php`, which first merges any pre-existing duplicate `(session_id, media_item_id)` groups — keeps the max `updated_at`, tie-break max `id`, batched via the new `Phlix\Session\PlaybackStateDeduper` so a large production table drains safely across multiple bounded passes — so the `ADD UNIQUE KEY` cannot fail 1062. **⚠️ Post-deploy one-time step (like `cleanup_072.php`): after migration `090` is applied, run `php migrations/cleanup_090.php` ONCE.** `scripts/install.sh` and the Docker entrypoint do NOT run it automatically; until it runs, the unique key does not exist and progress writes keep duplicating. The script is idempotent and re-run-safe (the dedupe is a no-op once clean and `addUniqueKey()` swallows a duplicate-key-name; re-run it if it reports lingering duplicates). - **Top Media / Top Users leaderboards no longer show blank rows for deleted items/users** (updates.md #14 / S14). The admin dashboard's Top Media list previously rendered play-count rows with no title/poster (and Top Users rows with no username) whenever a media item or user that had recorded plays was later deleted — the aggregate queries counted the surviving `stats_playback_events` rows while the per-row hydrate found nothing to render. `StatsCollector::getTopMedia()` / `getTopUsers()` now `INNER JOIN media_items` / `users` (`mi ON e.media_item_id = mi.id` / `u ON e.user_id = u.id`), so events whose item or user no longer exists are excluded at the query level, mirroring `WatchHistoryService`. A defense-in-depth null-skip in `DashboardService::getTopMedia()` / `getTopUsers()` (`continue` when `findById()` / `getUsernameById()` returns null) closes the narrow TOCTOU window if a row is deleted between the aggregate query and the hydrate. **Decision: orphaned rows are hidden**, not shown as a "(deleted item)" placeholder carrying the historical play-count. The joins are 1:1 on the PKs, so watch-time / play-count / ordering semantics for surviving items and users are unchanged, and the underlying `stats_playback_events` rows are retained (purging orphaned stats is a separate maintenance task, S77). New positive coverage in `DashboardServiceTest` (null-skip) and `StatsCollectorTest` (INNER JOIN present). - **DLNA Start/Stop toggle now genuinely enables/disables the ContentDirectory service** (updates.md #28 / S28). The admin `POST /api/v1/admin/dlna/start` and `/stop` endpoints previously only flipped an in-memory boolean on the single request-worker's `CdsServer` (the SSDP announce), which neither registered the browse routes, reached the other worker processes, nor survived the next reload — the ContentDirectory routes are registered once per worker at boot in `Application::loadCdsRoutes()`, gated on the effective `dlna.cds_enabled` setting. `start()`/ `stop()` now **persist** `dlna.cds_enabled` via the shared `SettingsRepository` (`set(..., 'bool')`, the same store the generic admin settings page writes) and schedule a **graceful SIGUSR2 reload** via a new reusable `AdminRestartController::scheduleGracefulReload()`, so every worker re-reads the setting at its next `onWorkerStart` and registers or drops the CDS routes accordingly. Idempotent — `409` when already in the desired state (no write, no reload); `503` when the settings store is unwired; `500` if the persist throws. `GET /api/v1/admin/dlna/status` now reports truthfully: `enabled` (persisted intent, read live from the store), `running` (whether THIS worker is actually serving the routes right now, frozen at its boot), and a new transient `reloadPending` (`enabled !== running`) the SPA can surface as "applying…". The reload is a Workerman one-shot timer + `posix_kill` probe — no blocking I/O, exit, or static request state in the request path. New `AdminDlnaServerControllerTest` (11 tests) and first-ever coverage of `scheduleGracefulReload()` in `AdminRestartControllerTest`. - **DLNA ContentDirectory SOAP control parsing hardened against DIDL-Lite argument bleed** (updates.md #28 / S28). The live control parser `DlnaContentDirectoryController::parseSoapBody` (the `POST /dlna/content_directory` path) walked the whole document with `XMLReader`, taking the first text value for each localName found at ANY depth — so a same-named element nested inside embedded DIDL-Lite metadata (e.g. `<Filter><ObjectID>injected</ObjectID></Filter>`) could bleed into a top-level argument. A new namespace-aware, XXE-safe `Phlix\Dlna\SoapArgumentExtractor` reads arguments ONLY from the SOAP action element's DIRECT children (XPath `*`, so a wrapper element carrying nested metadata contributes no text and is skipped), parsing with `LIBXML_NONET` and never `LIBXML_NOENT`/`LIBXML_DTDLOAD` so external entities are never substituted. `DlnaServer::parseSoapBody` was de-duplicated onto the same helper (single source of truth, so the two parsers cannot diverge). The legacy `/cds/control` handler (`CdsControlHandler::parseSoapEnvelope`) was already direct-child scoped and is left unchanged. Real Browse/Search envelopes are unaffected; the empty-body `400` and the unknown-action UPnP fault are preserved. New `SoapArgumentExtractorTest` (18 tests) plus a regression test asserting a nested same-named element does not bleed into the extracted arguments (mutation-verified against the old any-descendant walk). **S218 follow-up — `u:Search` now has LIVE-path coverage, and the dead pointer is labelled.** Before this, `grep -F 'u:Search' tests/` hit a docblock and `DlnaServerTest.php` only. Those cases drive `DlnaServer::processSoapRequest()`, which has **no production caller**: a repo-wide search (src/, tests/, `vendor/`, and the sibling repos under `/home/sites/phlix`) finds its definition, one docblock mention, and seven callers — every one of them inside `DlnaServerTest.php` — and `DlnaServer` performs no dynamic dispatch. The container builds the class only for `getContentDirectory()`, `getScpdXml()` and the device description. So Search was covered only on code no renderer can reach, while Browse alone was covered on the served path. New `tests/Unit/Server/Http/Controllers/Dlna/DlnaContentDirectorySearchLivePathTest.php` (6 tests) drives real namespaced `u:Search` envelopes with a real `SOAPACTION` header through `DlnaContentDirectoryController::handle()` — the `POST /dlna/content_directory` route registered in `Application::loadCdsRoutes()`. It pins: the arguments reaching `ContentDirectory::search()` verbatim; a Browse **control** on the same driver taking the other `dispatchAction()` arm (so the Search assertion is discriminating, not just "200 OK"); an end-to-end filter through a REAL `ContentDirectory` (two rows in, one out — a Search that degraded to a Browse returns both); the DIDL-bleed hardening in a Search-shaped variant (nested `<SearchCriteria>` inside `<Filter>`, no top-level one); and UPnP error 800 surfacing as a fault rather than an empty 200. An anti-vacuity floor asserts the fixture really is a namespaced `<u:Search>` first child of `<Body>`, so the coverage cannot silently weaken below its own description. `DlnaServer::processSoapRequest()`, `SoapArgumentExtractor`'s class docblock and `DlnaServerTest`'s class docblock now each say, in place, that this path is not served and where live coverage belongs. No behaviour changed. - **CSP `img-src` allowlists the TMDB image CDN so remote poster/backdrop/cast artwork renders** (updates.md #1). `SecurityHeaders::contentSecurityPolicy()` previously served artwork loaded directly from TMDB only under the default `'self'` policy, so any image not yet cached on our origin was blocked by the browser. `img-src` now explicitly names the two TMDB hosts — `https://image.tmdb.org` and `https://tmdb.org` (no wildcard) — alongside `'self' data: blob:`. This is a **stopgap**: an inline `// TODO(updates.md #47 / S71-S73)` marks it for removal once the generic image caching/loader work proxies all remote artwork through our own origin. Single source of truth in `SecurityHeaders`, also consumed by the `/app` shell's nonce'd variant; covered by an extended `SecurityHeadersTest` asserting both hosts are present and no wildcard is used. - **The last two dishonest `restart: true` settings are now honest** (closes out the item below, which fixed 14 of 16 keys). Bumps `detain/phlix-shared` to **v0.26.0** (43 → 42 settings keys). **`hwaccel.probe_timeout` — DELETED, not wired.** It resolved to a config default, so it passed every resolvability test, but **no code ever read the effective value** — while the admin UI still rendered "requires a server restart to take effect" beside it. Two independent causes, both re-verified: `HwaccelRegistry` is built via `getInstance()` with no config (`FfmpegRunner.php:1359`) and its `initialize()` hands `HwaccelProbe` only a binary path (`HwaccelProbe.php:51`); and `HwAccelConfig::get()` resolved `$transcodingConfig['probe_timeout'] ?? $hwaccelBase['probe_timeout']` (`HwAccelConfig.php:103`) while `config/transcoding.php:33` always declares the key, so the `hwaccel.*` side could never win. Deleted rather than wired (plan §4 rule 10) because wiring is neither cheap nor safe: the real timeouts are **two different** hardcoded constants (`ShellTimeout::FFMPEG_TIMEOUT = 10`, `::GPU_TOOL_TIMEOUT = 5`) reached through 22 static call sites across seven `VendorProbe` classes; the schema permitted `minimum: 0` and coreutils `timeout 0 CMD` means **no timeout**, which would let an admin hang a resident worker at boot — precisely what `ShellTimeout` exists to prevent; and the shipped `helpText` described a per-file pre-transcode probe that does not exist (the real one is a one-time process-wide capability scan). `config/hwaccel_base.php` and `config/transcoding.php` keep their `probe_timeout` literals, so the merged array shape is unchanged — it is simply no longer an admin setting. Flipping `restart` to `false` was rejected: that would claim the key is *live*, which is even less true. **`process.<worker>.enabled` (5 keys) — the half-live asymmetry is now disclosed in the UI.** The old `helpText` claimed the worker "is not spawned", which is **false**: `start.php` spawns it regardless and the gate lives in each worker's `onWorkerStart`, which logs and skips arming the poll timer. Since the admin only ever sees `helpText`, all five now state that turning a worker OFF applies after a restart; that turning one back ON also applies after a restart *unless* it is disabled in the on-disk `config/process.php`, in which case the in-app Restart button (SIGUSR2, a graceful reload of the already-executed master) cannot help and the service itself must be restarted; and that a worker switched off here still occupies an idle process. No behaviour change — the gate is unchanged and correct; only the promise made to the admin was wrong. Server-side: the hand-written allow-list lock-in and key-count assertions in `tests/Unit/Server/Http/Controllers/Admin/AdminSettingsControllerTest.php` go 43 → 42 (the list is deliberately **not** derived from the schema — mutation-verified: re-adding the deleted key to it turns the test red). Two tests that used `hwaccel.probe_timeout` merely as a convenient int-typed key now use `marker_detection.intro_max_duration`. `tests/Unit/Admin/SettingsDefaultResolvabilityTest` still passes with **every** key resolving. `docs/dev/settings-restart-gap.md`'s per-key table updated to final reality. - **`restart: true` settings now actually take effect on restart.** The shared settings schema marks 16 boot-only keys `"restart": true` and the admin SPA renders "requires a server restart to take effect" beside each one — a promise nothing kept. `SettingsRepository::getEffective()` resolves `override ?? default`, but almost nothing called it: every other consumer read the boot `$config` array (which `start.php` `include`s ONCE in the master and closes over) or `include`d a `config/*.php` file straight from disk, and **nothing merged the `server_settings` table into either**. Changing one of these settings and restarting — even a full `systemctl restart` — did nothing. New `Phlix\Config\EffectiveConfig` loads the persisted overrides once per process and overlays them onto the config defaults using the same dotted-key semantics as `SettingsRepository` (leading segment = config *file*, remainder = path inside it). It is called as `bootstrapAndOverlay($config)` at the top of every `onWorkerStart` in `start.php` (HTTP, WebSocket, hub-heartbeat, relay-tunnel, managed workers) **and** in `public/index.php`, before `ContainerFactory::create()`, so every DI provider that reads boot config observes the effective value with no per-provider wiring; consumers that bypass `$config` by `include`ing a config file directly (`HwAccelConfig::get()`, `FfmpegRunner::getTranscodeTimeout()`, `Recorder::getTranscodeTimeout()`) now read `EffectiveConfig::file()` instead. **Note the recommended design was wrong and was corrected:** overlaying only the boot `$config` array covers just 4 of the 16 keys, because dotted keys name config *files* and only `server.hls.*` lives in `config/server.php`. **15 of 16 keys are now live after a restart or the in-app graceful reload.** Two honest exceptions, documented rather than hidden: `hwaccel.probe_timeout` has **no consumer at all** (`HwaccelRegistry` is constructed without this config) and is additionally shadowed by `config/transcoding.php`'s own always-present `probe_timeout` — it needs a `phlix-shared` follow-up to either wire it up or drop the key; and `process.<worker>.enabled` can *disable* a worker on reload (the gate moved into the worker's own `onWorkerStart`, which skips arming the poll timer and logs) but cannot *enable* one that `config/process.php` disables on disk, because the master's spawn loop runs before `Worker::runAll()` and Workerman cannot fork a Worker group afterwards. A settings-store failure — DB down, `server_settings` missing on a fresh install — degrades to the shipped file defaults rather than crash-looping the worker, and an override is applied only where the default already exists, so a malformed or unknown persisted row cannot inject a config key. Per-key status table and the reasoning behind both exceptions live in `docs/dev/settings-restart-gap.md`, rewritten from "known gap" to reflect the fix. - **Trakt operator credentials are settable from the admin Settings page again.** The v0.24.0 schema sweep (above) deleted `trakt.client_id`, `trakt.client_secret` and `trakt.redirect_uri` because `SettingsRepository::getDefault()` resolves a two-segment key like `trakt.client_id` against a FLAT `config/trakt.php`, and the real config lives at `config/scrobblers/trakt.php`. The *read* path never broke — `TraktOAuthController::SETTING_KEY_MAP` (`src/Server/Http/Controllers/TraktOAuthController.php:63-67`) maps those keys itself, so installs with existing overrides kept working — but the admin `PUT` allow-list is derived from the schema, so saving any of the three started failing with `{"errors":{"trakt.client_id":"Unknown setting key."}}`. Operators were left with env vars and a file edit. **Fix:** new `config/trakt.php`, a one-line re-export (`return require __DIR__ . '/scrobblers/trakt.php';`) using the same idiom `config/hwaccel.php` already uses for `config/hwaccel_base.php`. The three keys resolve again under their **original flat names** — no rename, no orphaned `server_settings` rows, no read-path change, no migration. `config/scrobblers/trakt.php` remains the canonical file; the shim holds no values of its own and is asserted to return the identical array. They were deliberately **not** renamed to `scrobblers.trakt.*`, even though `getDefault()` now walks nested config paths and would resolve that form: `trakt.*` overrides are already persisted on live installs and are what the controller reads, so a rename would silently drop working Trakt credentials on upgrade. Both credential halves are masked (`"secret": true` upstream), matching this schema's existing treatment of `lastfm.api_key` — the exact analogue, since Phlix sends the Trakt client_id as the `trakt-api-key` header on every request. `trakt.redirect_uri` is a public URL and is not masked. The documented secret set grows from a trio to a quintet. `"restart": false` on all three is verified rather than assumed: `TraktOAuthController::applySettingsOverrides()` (`:414-429`) calls `SettingsRepository::getOverride()` at `:421` on every request via `loadConfig()` (`:402`), reached from `authorize()` (`:158`), `callback()` (`:226`) and `status()` (`:511`). A saved credential is live on the very next request. These are among the few settings for which `restart: false` is literally true — the general boot-only override gap remains open and is tracked in `docs/dev/settings-restart-gap.md`. New `tests/Unit/Server/Http/Controllers/Admin/TraktSettingsEndToEndTest.php` asserts the **consequence, not the shape**: that a `PUT` is accepted and persisted, that the saved client_id is the one that actually ends up in the Trakt authorize redirect (`status()` flips to `configured: true` and `authorize()` goes 503 → 302 with no restart), that the secret never appears in a GET or PUT body, and that re-submitting the mask sentinel leaves the *stored* credential intact and Trakt still working. Mutation-verified: deleting the shim, removing the keys from the vendored schema, and disabling the PUT mask guard each fail it. - **`POST /api/v1/admin/restart` could not work on any real box.** Three separate defects: 1. **The PID file nothing wrote.** The controller read `config/server.php`'s `worker.pid_file` (`/var/run/phlix/pid`), but `Worker::$pidFile` was never assigned anywhere in the repo, so Workerman used its own default (`dirname(start.php)/workerman.start.php.pid`). Every call returned HTTP 500 "PID file not found". New `Phlix\Server\Runtime\PidFile` is applied from `start.php` before `Worker::runAll()` so the writer and the reader agree; it creates the directory when needed and degrades to Workerman's default (with a stderr warning) rather than aborting boot if the location is unusable. 2. **The signal was inverted.** In Workerman, `SIGUSR2` is the *graceful* reload and `SIGUSR1` is the *non-graceful* one (`Worker::reload()`: `$sig = getGracefulStop() ? SIGUSR2 : SIGUSR1;`); SIGUSR1 stops children immediately and arms a `SIGKILL`. The endpoint sent SIGUSR1 while both docblocks asserted the exact opposite. It now sends `SIGUSR2`, and the docblocks are corrected. 3. **The signal fired mid-request.** `posix_kill()` ran before the `Response` was even built, so the caller could get a connection reset instead of the ack — which the SPA renders as "Failed to restart server" for a restart that did happen. The handler now pre-flights the target with `posix_kill($pid, 0)` (a probe that sends no signal, so a stale PID is still a real 500) and defers the actual `SIGUSR2` to a Workerman one-shot timer, so the JSON ack flushes first. The response body is `{"success":true,"message":"Restart signal sent"}`. The tests were rewritten to exercise the pid-path agreement between `start.php`, `config/server.php` and the DI provider, and to assert which signal is sent and that it is deferred — none of which the previous `sendSignal()`-stubbing suite covered. - **`ffmpeg.max_concurrent_transcodes` is no longer a fake setting.** `TranscodeManager::__construct()` assigned a bare literal `$this->maxConcurrentTranscodes = 4;` — not a constructor parameter, not a config read — so `config/ffmpeg.php`'s value and its admin-UI setting (min 1 / max 64 / default 4, whose help text advises "a 16-core CPU with an NVIDIA GPU can typically handle 6–8") were both inert. An admin who set 8 still got 4. The ceiling is now a constructor parameter resolved by `TranscodeServicesProvider` from the **effective** value (config default merged with any `server_settings` override), with `DEFAULT_MAX_CONCURRENT_TRANSCODES` as the fallback. A settings-store failure degrades to the config default rather than breaking transcoding. - **`SettingsRepository::getDefault()` can now resolve nested config paths.** The dotted-key rule treated the first segment as the entire filename, so `config/scrobblers/trakt.php` — a real, shipped config file — was unreachable by any key, and anything pointing at it resolved to a `null` default. The file part may now span subdirectories (longest match wins: `scrobblers.trakt.client_id` → `config/scrobblers/trakt.php`'s `client_id`). Purely additive — a multi-segment file path never resolved before — and the path jail is tightened to validate every segment, so no key can escape `config/`. _Folded in from the former `### Fixed (live production batch)` heading (W-min90 closeout merge): the entries from here through the end of this section's production-hotfix run shipped as a live production batch._ - **Continue-Watching rail 500 on both clients (MySQL error 1060).** `PlaybackController::getContinueWatching()` emitted a duplicate `mi.id AS id` column in its derived table, which MySQL rejected with `ERROR 1060 (Duplicate column name 'id')`, 500-ing the CW rail on the SPA and console. The duplicate projection was removed. - **Continue-Watching poster/progress shape for authless clients.** CW rows are shaped via `MediaItemShaper` so each carries top-level `id` / `poster_url` / `runtime` (episodes resolve the **series** poster); additionally the nested `metadata.poster_url` is **re-minted** at response time so the console (which reads the nested metadata) gets a fresh signed artwork URL rather than an expired one. - **Signed artwork URLs expiring for authless clients (console 401s).** `poster_url`, `poster_srcset`, `backdrop_url`, `backdrop_srcset`, and `logo_url` are now **re-signed at response time** (`SignedUrl::refreshArtworkUrl()` / `SignedUrl::refreshArtworkSrcset()`), so clients that cannot re-authenticate a request (e.g. the console) no longer receive `401`s on artwork whose signature had aged out. - **PHP 8.5 — logging + PDO hardening under coroutines.** The MySQL buffered-query attribute now uses the `\Pdo\Mysql::ATTR_USE_BUFFERED_QUERY` class constant (the global `PDO::` alias is deprecated on 8.5), `connect()` suppresses the PDO `E_DEPRECATED`, and every Monolog handler is wrapped in `WhatFailureGroupHandler` so a concurrent-coroutine `E_DEPRECATED` raised mid-write can no longer crash the log write (which had surfaced as fatal request failures during metadata matching). - **Spurious "circular dependency" 500 from a php-di coroutine race.** `WebPortalRouter` is now **warmed once at worker start** (`start.php`), so concurrent coroutines no longer collide on php-di's in-progress resolution guard and mis-report a circular dependency. - **RelayConsumer `connect()` debug logging TypeError.** The connect-path debug log is now null-safe, so a synchronous disconnect (null connection) no longer throws `spl_object_id(null)`. - **Transcode — undecodable 10-bit H.264 on the hwaccel software-fallback path.** When hardware accel is enabled but only a software encoder is registered, segments were re-encoded to **10-bit** H.264 ("High 10") from 10-bit HEVC sources — which no browser can decode. That path now forces browser-safe **8-bit** output (`-pix_fmt yuv420p -profile:v high -level 4.1`), matching the pure-software path; true hardware encoders keep their own `nv12` 8-bit output. `TranscodeManager::JOB_KEY_VERSION` was bumped **v3 → v4** so any cached 10-bit segments are invalidated and regenerated as 8-bit. - **Transcode — HLS audio now downmixed to stereo AAC for browser compatibility.** A re-encoded HLS audio stream from an **AC-3 5.1(side)** source (side channels SL/SR) produced AAC that FFmpeg's native encoder tagged with `channel_configuration=0` (a PCE) in the ADTS header, which **hls.js cannot parse** — it failed to build the audio MSE `SourceBuffer` and the whole player load errored **even with valid 8-bit H.264 video**. Re-encoded audio is now forced to **stereo** (`-ac 2`, `channel_configuration=2`, universally browser-safe) via the new `FfmpegRunner::browserSafeAudioChannels()` helper, applied on every re-encode path (direct-play `audio_codec === 'copy'` passthrough is untouched). `TranscodeManager::JOB_KEY_VERSION` was bumped again so any cached surround-AAC segments are invalidated and regenerated as stereo. Together with the 8-bit video fix above and the CSP `blob:` allowances below, this made a **10-bit HEVC + AC-3 5.1** episode actually play in a normal browser (confirmed via headless Chrome under enforced CSP) — browser HLS playback requires **all three layers**: 8-bit video **+** stereo AAC audio **+** CSP `blob:` allowances. - **CSP — SPA now allows `media-src`/`worker-src 'self' blob:` so browser HLS playback works.** The SPA Content-Security-Policy (`SecurityHeaders::contentSecurityPolicy()`) added `media-src 'self' blob:` and `worker-src 'self' blob:`, required so hls.js can attach its MSE `blob:` object URL to the `<video>` element and spawn its `blob:`-sourced transmux Web Worker. Without them a strict browser rejected the load with `MEDIA_ELEMENT_ERROR: Media load rejected by URL safety check`, blocking **all** HLS/transcoded playback. - **CSP — `/app` shell inline bootstrap now runs under a per-request script nonce (no `'unsafe-inline'`).** The `/app` SPA shell's inline `window.__PHLIX__` bootstrap `<script>` now carries a per-request cryptographically-random **nonce**: `SharedUiController` emits `<script nonce="…">` and sets a matching CSP built via `SecurityHeaders::contentSecurityPolicy($nonce)` (adds `'nonce-…'` to `script-src`), so the inline block executes **without** weakening `script-src` to `'unsafe-inline'`. Non-`/app` responses keep the strict default policy (no nonce). - **Transcode — 480p rung no longer 404s on a 16:9 source (odd-width reject).** A 480p HLS rung scaled from a 1920×1080 source computed **853×480** — an **odd width** that libx264 rejects, so the rung 404'd. The transcode scale filter now appends `:force_divisible_by=2` so widths round to an even value. `TranscodeManager::JOB_KEY_VERSION` was bumped **v5 → v6** to invalidate any cached/failed rungs. - **WS-A — Continue-Watching now shows poster images and a progress bar in the `/app` SPA.** `PlaybackController::getContinueWatching()` (which backs both `GET /api/v1/me/continue-watching` and `GET /api/v1/users/me/continue-watching`) now runs every row through `MediaItemShaper::shape()`, so each entry gains the same top-level fields the SPA `MediaCard` renders: `id` (= **media item id**, so cards navigate to the correct detail page — previously the playback-state id), `poster_url`, `poster_srcset`, `runtime` (minutes), `year`, and `rating`. For **episodes** a two-level `LEFT JOIN` (season → series) resolves the **series** poster (falling back to the season poster, then the episode's own poster) *before* shaping, replacing the TMDB still/screengrab that previously showed. The playback-progress fields `position_ticks` and `duration_ticks` are re-attached at the top level (the SPA `useResumeSync` reads `position_ticks` for the progress bar), and `parent_id`, `media_item_id`, and the full `metadata` map are preserved so the console `fromContinueWatching` mapper and the profile rating-gate (`media_item_id` filter) keep working. `ROW_NUMBER()` de-dup (one row per media item, newest first) is retained. Tests: strengthened unit assertions on the top-level `poster_url`/`runtime`/`id` in `tests/Unit/Session/PlaybackControllerTest.php`, plus a new real-MySQL integration test `tests/Integration/Session/ContinueWatchingIntegrationTest.php` that seeds series/season/episode + movie + duplicate playback rows and asserts the shaped output including the dedup. - **WS-C — Logging no longer triple-writes to app.log, events.log, plugins.log.** Previously every handler attached to every per-channel logger, so each debug/info record was written to `app.log`, `events.log`, and `plugins.log` at once (identical firehoses) and the two subsystem logs were polluted with unrelated records. Handlers in `config/logger.php` are now routed by two optional gates that `StructuredLogger::setupHandlers()` evaluates per channel: `channels` (absent/empty = attach to ALL channels) and `env` (attach only when the named env var is truthy). Resulting routing: `app.log` is the **master firehose** — the untagged `file` handler still captures every channel at debug+ (nothing is lost); `error.log` is the **severity mirror** — untagged too, so all channels at error+ aggregate there; `plugins.log` is scoped to the `PLUGINS` channel only (plugin lifecycle); `events.log` is scoped to the `EVENTS` channel **and** gated behind `PHLIX_DEBUG_EVENTS`, so it stays **empty by default** — event-dispatch records still land in `app.log`, so no diagnostic coverage is lost when the switch is off (this is why an operator sees an empty `events.log` without `PHLIX_DEBUG_EVENTS=1`). Also removed a dead `'default' => 'file'` key from `config/logger.php` that `setupHandlers()` never read (the catch-all comes from the untagged handler, not that key). Routing helpers `handlerAppliesToChannel()` / `handlerEnvEnabled()`; tests in `tests/Unit/Common/Logger/StructuredLoggerTest.php`. - **WS-D — phpstan cast in `WebPortalRouter::getMusicTracks()`.** The music-tracks row mapper cast a `mixed` id straight to `string`; it now guards with `is_string`/`is_scalar` before casting (empty string otherwise). Behavior is unchanged for the real (string) rows; the fix clears the level-9 error surfaced when wiring up the newly-reachable `/app/music/tracks` page. - **PHP 8.5 compatibility — removed deprecated no-op `curl_close()` / `imagedestroy()` calls.** Both have been no-ops since PHP 8.0 and emit `E_DEPRECATED` under PHP 8.5, which surfaced as fatal log-write failures during TV-series metadata matching (16 `curl_close()` sites across 13 files; 19 `imagedestroy()` sites in `ArtworkStorage`/`AvatarStorage`/`PhotoController`). Behavior is unchanged — the calls did nothing. (`finfo_close()` was already removed previously.) - **Admin Logs page — removed a redundant `[LEVEL] <datetime>` prefix** that ~37 Monolog call sites (across 6 worker/handler files) hand-built into their message strings. Monolog's `LineFormatter` already emits `[<iso8601>] <channel>.<LEVEL>: <message>`, so the hand-built prefix doubled the level and timestamp on every rendered line; each record is now a single, clean level/timestamp. (`error_log()` sites are untouched — there the prefix is the only metadata.) The client-side Logs renderer overhaul ships in `@phlix/ui` v0.82.0. - **Router `HEAD` fallback for static-only `GET` routes.** A `HEAD` request now correctly falls back to a purely-static `GET` route even when no parametric `GET` routes are registered (previously the fallback could 404 or warn on a null iteration). - **SV-4.16 — JWKS endpoint 500 on operator-supplied Ed25519 keys.** `GET /.well-known/jwks.json` returned an unhandled **500** whenever `config/hub-server-key.pem` was a standard PKCS#8 Ed25519 key (`-----BEGIN PRIVATE KEY-----`, as produced by `openssl genpkey -algorithm Ed25519`) — `Ed25519KeyManager::parsePem()` only accepted the app's custom `-----BEGIN ED25519 PRIVATE KEY-----` label wrapping a raw libsodium secret. The reader now accepts **both** formats: for PKCS#8 it validates the Ed25519 OID (`1.3.101.112`), extracts the 32-byte seed, and expands it via `sodium_crypto_sign_seed_keypair()` to the 64-byte secret the rest of the code expects (non-Ed25519 PKCS#8 keys are rejected at the OID gate; the native format parses exactly as before). The writer (`buildPem()`) still emits the native format — no key rotation or identity change. As defense-in-depth, `HubClient::getPublicKeysJwk()` now catches any key-load failure, logs it at **ERROR**, and serves a valid RFC 7517 `{"keys":[]}` at HTTP **200** instead of letting the exception escape as a 500 (the `429` rate-limit path is thrown before this and is unaffected). - **item-5+ — WebSocket connection metrics recorded the loopback proxy IP and invented phantom rows.** The `:8097` S2 connection metrics now record the resolved **real** client IP (derived like the auth surfaces, not the loopback HAProxy peer). Additionally, connections that fire a bare-TCP `onConnect` but never complete the WebSocket handshake (health checks, port scanners, aborted upgrades) no longer produce a phantom `metrics_connections` row mislabeled `kind=http` with a null IP: the server now only touches (via `onClose` and the periodic touch timer) connections it actually opened after the handshake. - **item-5a — noisy per-segment hardware-accel diagnostics.** The per-segment `[HWACCEL_DEBUG]` transcode logs were emitted at **ERROR** and carried a per-call `debug_backtrace()`; they are now logged at **debug** with the backtrace dropped, so normal hardware-accelerated playback no longer floods `error.log`. - **item-5c3 — enabled plugins do not re-attach their event listeners after a restart (still OPEN — a first fix was reverted).** `PluginLoader::bootstrapEnabled()` exists but is never called in any resident boot path, so PSR-14 event listeners (e.g. Trakt PUSH scrobbling on `PlaybackStarted`/`PlaybackStopped`) go dead after every restart until a manual re-enable. A first attempt wired `bootstrapEnabled()` into the HTTP + relay `onWorkerStart` (commit `f645edbc`); it was **reverted** (`f3c57fd8`) because several plugins' `onEnable()` perform **blocking network I/O at boot** (e.g. an anidb titles fetch), which stalled worker startup and, combined with a logger-write cascade, left workers half-initialized (surfacing as intermittent request failures on deploy). The correct fix must run plugin bootstrap **without blocking worker startup** (deferred/async, or making `onEnable()` non-blocking) — tracked as a follow-up. The single-shot CGI/FPM `public/index.php` path is unaffected (throwaway per-request container). - **The backup `label` option and the schedule body parameters are LIVE (S271).** `BackupController::create()` and `::updateSchedule()` read `$request->jsonBody` — a property that does not exist on `Phlix\Server\Http\Request` (the decoded body is `Request::$body`) — and the `?? []` swallowed the undefined-property null, so those handlers silently ignored every parameter a client sent: created backups always carried an empty `label`, and `auto_backup_interval_days` / `retention_count` never reached the config file they rewrite. A repo-wide sweep (git grep over `src/`+`scripts/` AND a filesystem sweep of the gitignored vendor tree — 10k+ files, with a `function __get` positive control so the zero is a measured zero, not an unsearched one) confirmed exactly these two sites read the name and nothing ever defined it. Both now read `->body`. Pinned by `BackupControllerBodyPersistenceTest`: a label driven through the real `BackupManager` write path is read back from the persisted `backups` row (plus the on-disk archive name), and schedule values are read back from the rewritten config file; reverting either property name reddens exactly one named test (mutation-verified). `MaintenanceController::runTask()`'s warning comment — which documented this defect as present and misattributed it to `::restore()` (which never read a body) — moved in the same commit. No migration. - **Two doc/comment corrections — zero behavior change.** The `AGENTS.md` S508 entry claimed `MediaItemController::getPlaybackInfo()` "honors" `?forceTranscode`/`?excludeHevc` through `QualitySelector::selectQuality()`; it does not — the pre-flight endpoint only **acknowledges** the parsed values (the add-only `playback_constraints` echo, exactly as its own docblock says), and no HTTP caller threads the options into `selectQuality()` yet. The `AGENTS.md` sentence now matches the code. Separately, the READS re-pin note in `tests/Unit/Server/Http/RequestDynamicPropertyCensusExecutableTest.php` attributed the census scanner's blind spot to `functionScopes()`; the actual mechanism is the dead final-parameter branch in `rootsDeclaredInParams()` (`$depth--` runs before the `$depth !== 1` segment-boundary check), which unregisters EVERY last parameter — any arity, any type or return shape. The corrected comment names the real mechanism so future re-pinners don't misjudge counts; all census pins (1877 files / 391 reads / 973 writes) are unchanged. - **F7 rework (CRITICAL): `AuthMethodPolicy` no longer memoizes its settings snapshot.** The class shipped in 1f8a09cc carried a per-instance `stateCache` whose docblock premise — "instances are request-scoped through the container" — is FALSE in production: PHP-DI memoizes built instances and each Workerman worker builds ONE container for its lifetime (the same resident-worker fact `AuthManager` admits for its in-memory rate-limit buckets and that forces the `AuthProviderBootstrapper` request-path self-heal). Consequences pinned by the reviewer: the write guards' base (`assertSafeTransition(currentState() + change)`) ran stale, enabling a live R2 bypass via any external/legacy DB write, and enforcement on warm workers kept honoring a method after it was disabled — contradicting the "applies immediately" help text. Design: the per-instance memo is deleted; every public decision (`isEnabled`, `currentState`) now takes exactly ONE fresh `getAllOverrides()` snapshot per call — fresh ACROSS decisions, single-SELECT WITHIN one (the ≤1-read-per-decision law is re-pinned, honestly re-counted). Cost: one small settings SELECT per login attempt (which already pays a user SELECT, an Argon2ID verify, and the bootstrapper's own per-call `getOverride`) and per admin toggle write — noise against the I/O around it; no request-lifecycle machinery invented. Regression pins: resident-instance liveness (build ONCE, flip the row through the same repo seam, both directions) and the production call-shape R2-bypass test (`array_merge(currentState(), touched)` → must now reject); self-mutation proof: re-planting the memo reddens exactly these three law pins. Docblocks corrected in the same commit (truth law), incl. the bounded once-per-worker-life fallback alarm and the accepted residual that password-off + open `auth.signup_mode` still mints fresh-account sessions (signup_mode is the account-creation control; the quintet governs sign-in factors only). - **W3 rework (P2): the two metadata settings policies now have a production-DI binding guard.** The adversarial review dropped BOTH `constructorParameter` lines (`confidencePolicy` on `MovieMetadataResolver`, `cachePolicy` on `MetadataManager`) and the full suite stayed green — each consumer silently falls back to a STORE-LESS `new MatchConfidencePolicy()` / `new MetadataCachePolicy()`, freezing `metadata.min_match_confidence` at gate-off and `metadata.cache_ttl_hours` at 24 h while every hand-built test passes the policy positionally. New `tests/Unit/Media/Metadata/MetadataPoliciesWiringGuardTest.php` copies the DiscoveryPolicyWiringGuardTest (W2) / SecurityHeadersPolicyWiringGuardTest (W4) shape: the production container must yield both policies store-backed AND thread them (store-intact) into both consumers. Mutation-proven: each single drop reddens exactly its consumer pin, the double drop reddens both, restore is byte-identical green. Test-only; census rotated 1972→1973 from the measured red. ### Added - **The reset-password recovery story is now fully shipped-truth per leg, and the two structurally toothless pins got real teeth (review server__seceng M-SERVER-33 r4):** the r2 paragraph had conflated the two refusal legs into one false sentence ("login and refresh are refused by `PasswordChangeRequiredException` … bare 500"). The truth is per-leg: the password-login leg really throws `PasswordChangeRequiredException`, which no HTTP boundary maps, so the Workerman last-resort catch answers a bare `500 Internal Server Error` and the error log names the class; the refresh leg throws a plain `InvalidArgumentException` that `AuthController::refresh` catches, answering `401` + `{error}` with no code field. The blanket refusal was also half-false: the latch gates the password path only — `WebAuthnController::finishAuthentication` (WebAuthn is default-enabled in `config/auth.php`) and `AuthManager::loginWithProvider` mint sessions without consulting `must_change_password` — and the endpoint row, recovery paragraph and handler docblock now scope the refusal to it and disclose the bypass. The CLI row's "hash only" understatement is cured too: `UserRepository::update` stamps `tokens_not_valid_after = NOW()` on every password write, revoking live sessions. Test-side (`PasswordResetRecoveryReadmeTest`, 4 tests, 13→32 assertions measured): the refusal pins are now per-sentence leg-scoped (the conflation sentence reddens — r2's probe-B install was green), and the `getDocComment()` pin hunting 'reset-password form' — a phrase that ever lived only in the method body — was replaced by a getStartLine/getEndLine docblock+body scrape pinning the fiction phrases out and the r2-cured truth phrases in, on their actual planes. Mutation battery N-R1..N-R5 each reddened exactly its named arm and re-greened on revert. Docs/comments/tests only: zero behavior change, zero new wire codes (registry md5 40c1da48 held); census `EXPECTED_PHP_FILES` stays 1995 unmodified — the sibling M38 rung rotated 1994→1995 at my base, and this lane carries zero PHP-file delta. The WsHandshake CI race r2's close-record flagged was already cured at the base by 05f90d60 (parity matrix builds liveness state at runtime). - **License metadata contradicted itself inside single files; every carrier now tells the one truth the repo ships — MPL-2.0 (review server__writer M-SERVER-44):** `composer.json:5` declares `MPL-2.0`, `LICENSE` carries the verbatim Mozilla Public License 2.0 text, the README license section explains the per-file weak-copyleft posture, and `TRADEMARK.md` is built on it — yet 881 PHP file/class header lines claimed `@license MIT` (boilerplate emitted by `scripts/add-headers.php`, whose template still seeded the false tag) and 8 of those same files additionally carried a third, mutually exclusive `@license Proprietary` + `@copyright 2024 Phlix Media Server` class-docblock block with no supporting evidence anywhere. Metadata-only cure: the header sweep rewrites all 881 claims to the canonical ` * @license MPL-2.0` (also folding the 14 non-canonical single-space variants into it), the 8 false `Proprietary`/6 stale 2024-copyright lines are deleted from the second docblocks (informative `@author`/`@see`/`@package` members kept), and `scripts/add-headers.php`'s template now seeds `MPL-2.0` so the false claim cannot regrow. Deliberately untouched as truthful self-consistent claims of *other* works: the vendored `examples/plugins/phlix-plugin-sample-theme/**` (MIT, matching the plugin/theme estate family), the `tests/Fixtures/Plugins/fixture-plugin/composer.json` `"proprietary"` value (installer-parser test input, not a license statement about this code), and npm dependency licenses in `web-ui/package-lock.json`/built assets. `@copyright 2026 Joe Huss <detain@interserver.net>` attribution held (matches estate authorship). Zero behavior change: 881 header lines flipped, 14 lines deleted, one script template string; census `EXPECTED_PHP_FILES` untouched (no files added or removed). - **The admin reset-password recovery story is told as shipped truth (review server__seceng M-SERVER-33):** the README documented `POST /api/v1/admin/users/{id}/reset-password` as "(returns new password)" while the shipped handler stores a hashed reset token, sets `must_change_password`, revokes the account's sessions and answers `{message}` only — no new password, and no shipped surface delivers or consumes the token. The cure is docs-only: the endpoint table row, the CLI-table caveat (`user:reset-password` writes the password hash but never lifts the latch) and a new "Bad days" recovery entry (two steps: `php bin/phlix user:reset-password` + the direct `UPDATE users SET must_change_password = 0` write — the only shipped clear path) now tell the truth, and the `AdminUserController`/`AuthManager` docblock fictions ("generate a new password", "reset-password form", "via the reset-token flow") are cured comment-only — zero behavior change, zero new wire codes (registry md5 40c1da48 held). The r1 draft recovery sentence claimed the refusal answers a `403` with a stable code; the r2 runtime trace corrected that too: `PasswordChangeRequiredException` (a RuntimeException) has no catch arm anywhere in src/, so the served Workerman path answers a bare `500 Internal Server Error` while the HTTP error log names the exception class — exactly what the README now says, with the code kept out of the docs until a real emitter ships (estate riders R1-R3). New `tests/Unit/Admin/PasswordResetRecoveryReadmeTest.php` (4 tests/13 assertions, measured) pins every load-bearing string runtime-derived from the enforcing code — including the anti-fiction pin that reddens if the unshipped wire code ever re-enters the README. Census `EXPECTED_PHP_FILES` 1993→1994, measured from the phpunit red. - **The 34 wire success codes openapi.yaml never told the truth about are corrected to shipped code, and the spec-currency gate gains a response-code leg (review server__fedev M24):** `OpenapiSpecCurrencyTest` pinned path/method/middleware bijection only, so response-status drift was structurally invisible — and 34 operations had it: 14 creates emit `->status(201)` (sessions, libraries, collections/playlists, profiles, profile schedules+tags incl. their admin-alias registrations, users, webhooks, plugin install) while the spec declared 200; 10 async-job enqueues (the nine `LibraryController` job endpoints + `POST /api/v1/admin/updates/check`) emit `->status(202)` with the `{job_id,status}` body their docblocks promise, declared 200; the two webhook deletes emit bare `->status(204)`, declared 200-WITH-a-body; and all eight OAuth endpoints (lastfm/trakt/github/oidc authorize + callback) 302-redirect with a `Location` header, declared as JSON 200 that was never returned. The cure is docs-only — the spec follows shipped truth (contract law: no runtime status line moved, no new wire codes, registry untouched). Adjudicated NOT-drift: the `/auth/register` and `/auth/login` browser aliases keep their `[302, 429]` declarations — `AuthController::isBrowserRequest()` (src/Server/Http/Controllers/AuthController.php:497) hard-routes every `/auth/*` request to the redirect contract, so the API-path 201/202/200 JSON emitters are unreachable there and the aliases' existing prose is truthful. `OpenapiSpecCurrencyTest` grows from 4 tests/10261 assertions to 6/10267 (measured): the 34-row adjudicated table asserts declared code, ghost-200 ban, 204-bodyless, 302-Location-shape, and that the emitter still carries the literal `->status(<code>)` (token presence, not a line pin — controller churn must not false-red the spec gate), plus a planted-reversion red-proof following the file's existing mutation pattern; both legs FAIL LOUD on the pre-fix spec (68 named problems = 34 ops × two laws). openapi.yaml +100/−110 lines. - **Auth-method lock-out guards get their documentation surface (review server__pm M-SERVER-30):** the `422` refusals on `PUT /api/v1/admin/settings` (auth-toggled write-sets) and `POST /api/v1/admin/auth-providers/{name}/disable` have always carried a machine-readable `reason` token (`all_methods_disabled` / `admin_lockout`, `AuthMethodLockoutException::REASON_*`) — but nothing anywhere explained the rules, the tokens, or the recovery, so an admin hitting the guard mid-workflow met an intentional safety behavior shipped as a mystery (grep of `admin_lockout|methods_all_disabled` across README.md and `docs/` was zero at the parent). Ships: a README `### Auth-method lock-out guards` section (five toggles, R1/R2, reason → what-to-do table, the sign-in-factor-vs-account-creation residual, the WebAuthn list/delete escape hatch, the `auth.methods_all_disabled_fallback` posture); an API-Reference `**Auth-method lock-out guard (422).**` error-meaning paragraph in the Rate-limiting idiom, with both guarded endpoint rows amended to anchor it; a recovery leg in "Bad days: recovery from the CLI" (a refusal persists nothing — the only reachable locked state is a bypass write, re-opened with the concrete `UPDATE server_settings SET setting_value = '1' WHERE setting_key = 'auth.password.enabled';` and taken live on the next decision, no restart); and `docs/dev/AUTH_METHOD_LOCKOUT.md` for integrators (envelope shapes, the B2 `FOR UPDATE` serialization law, absent-key semantics, fail-closed 500 siblings, accepted boundaries). New pin `tests/Unit/Admin/AuthMethodLockoutReadmeTest.php` (5 tests/19 assertions, red-on-parent measured 5F, token-drift mutation reddens exactly 1): every documented token/key/event string is derived at runtime from `AuthMethodLockoutException`, `AuthMethodPolicy::FALLBACK_EVENT`, `METHODS`/`settingsKey()` and the REASON_* constant set is asserted total. Census `EXPECTED_PHP_FILES` re-pinned 1990→1991 from the measured red (+1 — the new test file; staircase line carries attribution). Docs only: no new wire codes, no behavior or envelope change — the guard family (`AuthMethodPolicyTest`, `AdminSettingsControllerAuth*`, `AuthProviderControllerDisableGuardTest`, `AuthManagerAuthMethodGateTest`) stayed green UNMODIFIED (61 tests/164 assertions targeted). phpcs PSR12 + phpcs-tests.xml exit 0 on both touched test files; phpstan (tests config) clean. - **SRV-OPS remediation (2026-10-14 review family) — R14-09 stdout log sink + R3-08 `PHLIX_LOG_LEVEL` wired:** application logs previously reached only the repo's `.logs/*.log`, invisible to `docker logs` and `journalctl` unless someone tailed files. `config/logger.php` now ships a `stdout` handler (Monolog stream → `php://stdout`, all channels, INFO floor) gated OFF by default behind truthy `PHLIX_LOG_STDOUT` (`LogChannels::LOG_STDOUT_ENV`) — enabling it ADDS the sink, the file handlers keep working unchanged, unset means byte-identical routing (`LoggerConfigPolicyTest`'s S130 size-bound law amended to exempt only a `php://stdout` stream). And the long-documented-but-never-read `PHLIX_LOG_LEVEL` (`LogChannels::LOG_LEVEL_ENV`) is now real: `StructuredLogger::createHandler()` resolves every level-driven handler's floor from the env name when set — PSR-3 vocabulary case-insensitive, `warn` accepted — and fails SOFT on an unknown or empty value, leaving the shipped per-handler levels (`file` info / `error` error / `events`+ `plugins` debug) in force. Read at logger construction ⇒ a process-restart knob (README restart-semantics table). New `LogEnvironmentKnobsTest` (8 tests: shipped shape, env-gated attach, additive-sink proof, debug-loosens/error-quiets, case-insensitivity, fail-soft typo, empty-is-unset). - **Provider parity CLOSED: the B2 serialization protocol now guards BOTH auth write surfaces — the residual provider×admin interleave the B2 entry above documented as owed is eliminated, forward-correcting its "remains a documented residual" sentence (history kept verbatim above).** The transaction choreography was extracted verbatim from `AdminSettingsController::updateAuthGuarded()` into `AuthMethodPolicy::guardAndPersistThrough()` (+ the instance form `guardAndPersist()`): BEGIN → `lockSettingRows` over the five canonical `auth.<method>.enabled` keys in canonical order (the key set now lives as one private constant path, `AuthMethodPolicy::lockKeys()` — single source) → guard re-read against the locked latest-committed view → persist callback → COMMIT, ROLLBACK with zero persistence on any rejection. The admin surface kept its envelope mapping byte-identically on the wire — the extraction proof is `AdminSettingsControllerAuthLockTest` (6) + `AdminSettingsControllerAuthGuardTest` (6) + `AdminSettingsControllerTest` (43) + `AdminSettingsRealSchemaPutTest` (6) all green UNMODIFIED. `AuthProviderController::disableProvider()` now wraps `AuthProviderBootstrapper::disable()` in the same protocol via `guardAndPersist()`; typed failures arrive as new `AuthMethodGuardUnwiredException` / `AuthMethodGuardCheckFailedException` (500 fail-closed envelopes byte-identical to the pre-parity shapes). Because both surfaces lock the IDENTICAL five-key set in the IDENTICAL order (universal overlap), admin∥provider writers fully serialize and a partial-overlap deadlock pair cannot form. `enableProvider` stays outside the protocol — enabling is monotone-safe for lock-out (pins the zero-txn absence). Tests: NEW `AdminSettingsProviderLockParityTest` (7, real store/policy/bootstrapper on one recording Connection — accepted disable serializes BEGIN→LOCK→SELECT_ALL→UPSERT→COMMIT with the canonical 5-key lockParams, race-visibility via the same onLock injection idiom, ROLLBACK-with-zero-writes on both refusal reasons, enable zero-txn, 404 precedes the protocol, recorder tripwire law carried); NEW `AdminSettingsProviderLockParityB2Test` (3, real MySQL `phlix_b2p_*` scratch) — two-connection cross-surface probes in BOTH directions (admin-commits-password-OFF forces the provider revalidation to 422; admin-commits-password-ON lets the blocked provider disable proceed to 200; `innodb_lock_wait_timeout=1` as the sync primitive, no sleeps), final states asserted never-all-off. `AuthMethodPolicyWiringGuardTest` +1: the policy's locks and the bootstrapper's writes provably traverse ONE container-singleton `SettingsRepository` (the same-connection precondition — a factory-scoped split would be lock theater and reddens HERE first). Census: files 1976→1980 (+2 exception classes, +1 unit, +1 integration file), IntegrationDbGuard adopters 68→69, declared reads 448 / writes 1042 held (new tests stamp no `$request->` properties). No new wire codes; no migrations. Residuals honestly outside the protocol: raw-SQL/restore writers (read-path password fallback stays the backstop), and the bootstrapper's process-local registry mutation, which rollback cannot undo by design — the request-path self-heal re-reconciles every worker to the persisted flag. - **B2: the F7 auth-method lock-out guard now re-validates INSIDE a transaction under `FOR UPDATE` row locks — concurrent admin PUTs can no longer land the server all-off.** Re-verified at 759b0a79: `AdminSettingsController::update()` validated via `AuthMethodPolicy::currentState()` (a plain SELECT) before the persist loop with no lock — two concurrent PUTs (disable-password vs disable-webauthn) could each read a live snapshot showing the other's method still ON, both pass R1, both persist. Shipped: when (and only when) the validated write-set touches any of `auth.{password,webauthn,oidc,ldap,github} .enabled`, the write now runs `beginTransaction → lockSettingRows(5 keys, canonical order) → re-read guard state (current read under the locks) → assertSafeTransition → persist → commit`; any rejection ROLLBACKs and returns the byte-identical 422/500 envelope (contract surface unchanged — `AdminSettingsControllerAuthGuardTest` stayed green unmodified). The auth path additionally gains batch atomicity (all-or-nothing persistence, house idiom `CollectionItemRepository::applyMemberDiff`). `SettingsRepository` grows four public primitives (`beginTransaction`/`commitTransaction`/`rollbackTransaction` /`lockSettingRows`); honest lock coverage in the docblock: present keys take X row locks on `uq_server_settings_key`, absent keys serialize via RR next-key/gap locks (worst case a mutual gap-block deadlocks 1213 — the victim aborts fail-loud, so all-off still never lands; availability residue only), and writers OUTSIDE the protocol (the provider-integrations API keeps its plain pre-write guard — provider×admin interleave remains a documented residual; the read-path password fallback stays the backstop). `GET_LOCK` deliberately NOT shipped: it is connection-scoped and the pool borrows per-query, so it would leak; txn-scoped `FOR UPDATE` auto-releases on commit/rollback/connection death. Non-auth write-sets: zero new statements, zero new locks (pinned). Tests: `SettingsRepositoryTest` +3 (lock SQL shape/binds, empty-key fail-fast, txn delegation); NEW `AdminSettingsControllerAuthLockTest` (5) — real repository+policies over a recording fake Connection pinning statement order BEGIN→LOCK→READ→WRITE→READ→COMMIT, guard-liveness (a concurrent commit injected at the lock point becomes visible to the re-read and is refused), rollback-with-zero-persists on rejection, and non-auth zero-lock absence; NEW `AdminSettingsAuthLockB2Test` (2, real MySQL) — sequential last-man-standing refusal, and a two-connection probe proving the second writer BLOCKS on A's uncommitted lock (`innodb_lock_wait_timeout=1` as the sync primitive, no sleeps) and re-validates to 422 after A commits. Census: files 1974→1976, declared writes 1040→1042 (two put()-helper `$request->body` stamps), reads 448 held, IntegrationDbGuard adopters 67→68. Unit suite 11956→11964 (+8, measured against a 759b0a79 worktree baseline). No new wire codes; no migrations. - **Owner-gated vendor seam CLOSED: phlix-shared v0.51.0 re-vendored — the eleven deferred keys are admin-API-LIVE.** Tag `v0.51.0` cut on shared's release commit `01bd2a9` (annotated, peel-verified; 84-key `server-settings.schema.json`), and this repo moved `detain/phlix-shared` `^0.49.0`→`^0.51.0` with a surgical lock (only the package node + content-hash; the composer 2.9 `stability-flags`/ `platform-dev`/`plugin-api-version` tool churn was hand-reverted to keep the diff precedent-clean per the 0a2b95d0 shape; the vendored schema byte-matches the tag tree). What this admits: the F7 auth quintet (`auth.{password,webauthn,oidc,ldap, github}.enabled`), W2's discovery duo, W4's security duo (`hsts_max_age_seconds` bounds + `frame_options` enum now ENFORCED on PUT), and W3's metadata duo — the policies' "becomes PUT-able when the re-vendor lands" KNOWN-LIMIT docblocks are now post-seam truth. Pin rotations: `AdminSettingsControllerTest`'s hand-written allow-list map + both 73-counts → 84 (the family breakdown is annotated in the map itself); `SettingsDefaultResolvabilityTest` auto-covered all eleven keys at 84 with zero edits (their config defaults shipped ahead of the seam, as designed). The deferred F7 integration duty SHIPS: `tests/Integration/Admin/ AdminSettingsRealSchemaPutTest.php` — real MySQL, real vendored schema, real repositories — proving quintet admission + persistence, batch all-five-off → 422 `all_methods_disabled` with ZERO rows, R2 `admin_lockout` → 422 with zero rows, type violation (`1.5` for a bool) → 400 before the guard, schema-bounds rejects (`min_match_confidence=-0.5`, `hsts_max_age_seconds=99999999`, off-enum `frame_options`) → 400, and the in-range accepts persisting 200. Census: files 1973→1974, declared writes 1039→1040 (put() `$request->body` stamp), IntegrationDbGuard adopters 66→67. No new wire codes (contract law: 422 carries the F7 `reason`, 400 the existing validation envelope). - **W2+W4 settings exposure: discovery probe gates + security-header knobs (+ the W3 duo's schema half).** Three related slices, one lane (both schema-touching waves consolidated to avoid a phlix-shared collision): - **`discovery.ssdp.enabled` / `discovery.mdns.enabled` (W2, defaults TRUE — pure opt-out).** `DiscoveryServer::start()` previously armed its SSDP (60 s) and mDNS (30 s) advertisement timers UNCONDITIONALLY. New `src/Discovery/DiscoveryPolicy.php` (live `SettingsRepository` reads, no static caches, defaults-true coercion table) gates BOTH the boot-time timer registration and every tick body — the tick re-check makes flipping a probe OFF go live within one interval; flipping it back ON needs a process re-running `start()`, so the schema carries an honest `restart:true` with the asymmetry disclosed in its helpText. PHP-DI wiring via `DlnaServicesProvider` (named `constructorParameter`, mutation-proven by `DiscoveryPolicyWiringGuardTest`). **Reachability disclosed honestly:** `DiscoveryServer` is only started from `Application::run()`, which has no production caller (S171; `PublicFrontControllerRemovalGuardTest` pins the absence) — the Workerman daemon's discovery traffic is the separately-gated `phlix-dlna-ssdp` worker (`SsdpAdvertiser::isEnabledForConfig`). The keys exist to make the class correct-if-reached, mirroring the config/discovery.php `ssdp.enabled`/`mdns.enabled` names the file layer already carried. - **`security.hsts_max_age_seconds` (integer 0..31536000, default 31536000) + `security.frame_options` (enum `DENY|SAMEORIGIN|NONE`, default `SAMEORIGIN`) (W4, both `restart:false` — per-request header emission reads live).** New `src/Server/Http/Middleware/SecurityHeadersPolicy.php` clamps/coerces (negative → 0 = clear-the-policy intent, out-of-set frame value → `SAMEORIGIN`); `SecurityHeaders::decorate()` consults it per request (`HttpHandler` resolves via the container with a fail-safe default-policy fallback, memoised), so the shipped defaults emit a byte-identical pair to the old hardcoded constants. `NONE` is a structured sentinel that OMITS `X-Frame-Options` (CSP `frame-ancestors` stays). **CSP exposure deliberately refused** (DO-NOT-EXPOSE law, now docblocked at the generator): `media-src`/`worker-src blob:` and `style-src 'unsafe-inline'` are force-inject laws for hls.js MSE playback and runtime theming — free-form admin CSP would break the product. Bounds on both keys are mandatory in-schema (F9 lesson); out-of-bounds PUTs are rejected 400 by the existing `validateAgainstSchema` (justinrainbow) stage — no new wire codes. New `config/security.php` declares the defaults (net-new-file precedent: stats.php/dlna.php), which is what makes the keys resolve through the config layer TODAY, pre-vendor. - **W3's schema half + the owed entry.** `metadata.min_match_confidence` (number 0..1, default 0.0 = gate off) and `metadata.cache_ttl_hours` (integer 1..8760, default 24) were shipped SERVER-side at 6cbe692a (`MatchConfidencePolicy`, `MetadataCachePolicy`) without a CHANGELOG entry and without schema declaration — that attribution is this bullet. The schema keys now land in phlix-shared (5c4b59f); both policies' `KNOWN LIMIT` docblocks were rewritten to record the remaining vendor seam. Out-of-band `server_settings` rows are effective today via `getEffective()`'s DB layer. **Admin-API admission mechanism (report):** there is no separate allowlist to edit — `AdminSettingsController::allowedKeys()` is DERIVED from the vendored `server-settings.schema.json` (`loadAllowedKeysFromSchema`). The vendored copy is pinned at v0.49.1/73 keys by owner-gated seam; all six new keys (plus the F7 quintet) are admitted automatically when the schema is re-vendored at the next phlix-shared tag (v0.51.0 owed). `AdminSettingsControllerTest`'s 73-count pins rotate at that re-vendor, not now. **[CLOSED 2026-10-05:** v0.51.0 tagged and re-vendored at the top of this section — all 84 keys admitted, pins rotated, integration venue shipped.**]** **F16 stats dual-entry — verdict: superseded, pinned.** The literal dual-entry premise (start.php vs public/index.php bootstraps) died with S171's deletion of `public/index.php`; the surviving law — stats timers flow ONLY through `Application::startBackgroundTimers()`, called by both `run()` and the daemon's timer worker, all fork sites overlay via `EffectiveConfig::bootstrapAndOverlay` (7 sites) — is now structurally pinned by `StatsBootstrapSingleSourceTest`. **Deliberate non-ship dispositions (Phase-5 remainder):** IP allow/deny rows — NOT shipped: no enforcement point exists outside the reverse proxy and a settings key that nothing reads is the 213fce9d trap; revisiting needs a middleware design first. HTTPS-only (`force_https`) — NOT shipped: the server terminates plain HTTP on :8097 by contract (LAN/streaming clients), so a global toggle would be either inert or self-lockout; TLS-termination posture belongs to deployment. **Pre-existing defect disclosed (unfixed, no behavior change):** `SecurityHeaders` doccomments historically described HSTS as emitted "on secure connections" while the code always emitted it — the class has no TLS context; the comment now records the reality instead of the intent. Tests: `DiscoveryPolicyTest`, `DiscoveryServerGateTest` (timer-table reflection, 6 cases incl. live-flip tick silence), `DiscoveryPolicyWiringGuardTest`, `SecurityHeadersPolicyTest` (coercion/clamp table), `SecurityHeadersEmissionTest` (default pair byte-ident + configured values reach the wire twice per LIVE law), `SecurityHeadersPolicyWiringGuardTest`, `PhaseW2W4SettingsReachabilityTest` (all six keys over the REAL config dir with an empty-override-table stand-in), `StatsBootstrapSingleSourceTest`. Schema: phlix-shared 78→80 (568655e) →84 (5c4b59f). - **F7 auth-method toggles + lock-out fail-safe (owner-authorized flagship).** Five booleans — `auth.password.enabled`, `auth.webauthn.enabled` (default ON, absent-safe) and `auth.oidc.enabled` / `auth.ldap.enabled` / `auth.github.enabled` (absent = OFF, the live `AuthProviderBootstrapper::flagKey()` semantics, NOT re-migrated) — are now policy-enforced by a new SSOT, `src/Auth/AuthMethodPolicy.php` (DI'd, zero static caches, live reads like `PasswordPolicy`). Two rules: **R1** a state with all five methods OFF is refused; **R2/R2-bis** disabling a method is refused while ANY active admin (`findActiveAdminsForLockoutProbe()`, `is_admin=1 AND status='active'`) lacks a usable alternative factor under the proposed state (password hash / passkey rows / provider identity with the provider enabled AND configured). Both write surfaces are guarded BEFORE persistence and atomically: `AdminSettingsController::update()` (422 `{errors, reason}` when the batch touches a toggle) and `AuthProviderController::disableProvider()` (422; previously zero guard). Enforcement points fail CLOSED on policy errors: `AuthManager::login()` and the OPDS `verifyCredentials()` consumer refuse before any verify with the timing-flat `AuthMethodDisabledException` (`auth.method_disabled` rides the message/error text channel; the emit law forbids inventing code-channel literals outside the vendored registry, so no new wire `code` member); `WebAuthnController` login ceremonies answer the generic 401 `Invalid credentials` and NEW-credential enrolment 403 while the method is off, but credential list/delete stay LIVE always (gate the route, never delete the config — the escape hatch). The external providers keep self-healing through `ensureProviderRegistered` at all four request sites, now regression-pinned. If persisted state is somehow all-disabled (manual DB edit; API- reachable: zero), the policy answers password-ONLY and logs the one-time `auth.methods_all_disabled_fallback` audit event — never persisted. Config defaults declared in `config/auth.php` (`password.enabled`, new `webauthn.enabled` subtree, externals documented OFF). Schema side lands in phlix-shared (five properties, group `auth`, restart:false); server consumption of the new keys waits on the `detain/phlix-shared` re-vendor seam. Tests: `AuthMethodPolicyTest` (transition matrix + fallback posture), `AuthManagerAuthMethodGateTest` (pre-verify refusal, rate-budget charging, timing-flat burn), `WebAuthnControllerAuthMethodGateTest`, `AuthProviderControllerDisableGuardTest`, `AdminSettingsControllerAuthGuardTest` (synthetic-key seam — flips to real keys at re-vendor, zero test changes), `AuthMethodFlagsReachabilityTest` (213fce9d near-miss law) and a DI wiring guard. - **The LIST shape now carries `library_id` (owner-approved micro-feature, 2026-10-02 adversarial-review follow-through on the ui playlist-create fix).** `MediaItemShaper::shape()` emits a fixed whitelist built from the row, and it dropped `media_items.library_id` even though every list query is a `SELECT *` — the column existed pre-shape and only the whitelist removed it. Consequence: every grid/rail surface (`GET /api/v1/media` via `getMedia`, `getLibraryItems`, MostWatched, favorites rails) shipped items without `library_id`, so the ui 'Add to playlist' flow — which reads it off the item to resolve/create a playlist — was inert on exactly those surfaces, while `shapeDetail()` kept the key only via its raw-row merge. The new key is normalized with the same idiom as `parent_id` (scalar → string, empty/absent → null), so the wire type stays `string|null` on every row; hand-built rail payloads that never carry the column (`PlaybackController::getContinueWatching`'s projection, `WatchHistory`) ship null and stay null-safe. Detail behavior is byte-identical for real rows (shape()'s normalized value overwrites the raw merge with the identical string). No new routes, error codes, or migrations; the additive key is tolerant for dict-style clients. Tests: 7 new, all red-first against the pre-change tip — the exact ORDERED key-set whitelist pin (27 keys, `library_id` sixth, `path` fifth), value round-trip, absent-row null, empty-string null, list/detail single-source equality, a `dispatch()`-seam wire pin through `WebPortalRouter` (row value verbatim / key present + null without it), and a real-DB integration proof that EVERY `GET /api/v1/media` row carries the stored `library_id` and that the wire value equals a direct `SELECT library_id` byte-for-byte. - **Device-M1: casting sessions are now a cross-worker register, not per-worker memory (owner decision — the audit finding: the HTTP pool runs `count = 14`, each of the four casting managers keyed its live-session map process-locally, so a cast started on worker A answered pause/stop/status on worker B with a spurious 404 while worker A's poll timer kept firing for a session nobody could reach).** New table `casting_sessions` (migration 111: `session_id CHAR(36)` PK, `type`, `device_id` with a UNIQUE `(type, device_id)` register, `user_id CHAR(36)`, `state JSON`, `created_at`/`last_seen_at` with `NOW()` defaults — MySQL-5.7-safe, additive). `src/Casting/CastingSessionStore` (interface + record VO alongside) speaks the house pooled-`Connection` idiom: `WriteResult::wroteNothing`-guarded inserts with one delete-then-retry through the unique register (the replace race), a monotonic-clock touch throttle (60 s memo window so 5 s polls don't amplify writes into a column the sweep is the only reader of), `touch()`'s zero-affected path disambiguated by one bounded existence read — MySQL's `rowCount()` counts CHANGED rows, so a same-second re-touch of an identical DATETIME is not a death signal (caught by the real-DB proof, the doubled unit sibling could not see it) — and a lazy 24 h-horizon sweep riding every `find()`/`insert()` (bounded `DELETE … LIMIT 200`, best-effort like every other janitor here; deliberately no new resident timer: the table is only consulted on requests, so the sweep is exactly as fresh as the register is read). All four managers (`PlayToManager`, `RokuManager`, `CastManager`, `AirPlayManager`) take a trailing-optional `?CastingSessionStoreInterface` (store-less = byte-identical pre-lane behavior — 22 existing construction sites unchanged); `CastingServicesProvider` (appended to `ContainerFactory::defaultProviders()`) injects it in production because PHP-DI skips optional ctor params during autowiring. The local map stays the worker-local HOT CACHE; every control op is local-first, then re-attach from the row. All four device classes proved stateless-per-command on disk and therefore re-attachable — DLNA SOAP POST (`RendererControlClient` rebuilt from the stored AVTransport URL, re-passing `LanEndpointGuard`), Roku ECP HTTP, Cast HTTP JSON, AirPlay RAOP fresh-socket-per-command (`AirPlaySession` holds no socket — its ANNOUNCE/RECORD layer is command-simulated and carries no timer) — so no 409 stale-worker posture was needed anywhere. Genuinely-dead rows still answer the exact pre-lane 404 wire; no new error literals, routes/middleware tuples unchanged. Ownership moved with the lookup: controllers now thread `$request->userId` into every manager call; with a store active the manager refuses a local-hit whose owner mirror differs (no round trip), re-attaches only for the row's owner (cross-user → the same honest 404, refused-with-log), and start refuses closed without an authenticated identity. Start-path REPLACE semantics are preserved (stop-then-start, last writer wins) but now fleet-visible: the displaced worker's next poll `touch()` answers false and its object self-evicts. Orphan discipline completed: the DLNA 3-strike poll teardown (d052b488) gained the eviction seam it never had (self-destruct → map/owner cleanup + row delete; previously a strike-teardown left a dead object parked in the manager map), and Roku and Cast polls — which swallowed every failure and could poll forever — got the same `MAX_CONSECUTIVE_POLL_FAILURES = 3` strike counter plus the row-gone check on healthy polls. A DB blip never breaks a verified local session (control ops fail OPEN against their warm cache; re-attach fails CLOSED). Tests: 71 new — per-manager shared-store suites (identity gate, fleet registration, re-attach per class incl. device-tuple fidelity, cross-user refusal both directions, corrupt-row purge, replace-eviction, strike teardown via the real dead-endpoint transport, blip postures) on an in-memory `FakeCastingSessionStore`, a doubled- `Connection` store unit suite pinning statement shapes/order and the throttle memo, and a `RequiresRealDatabase` proof of the register (JSON round-trip, `uk_type_device` collapse, `NOW()`/`DATE_SUB` sweep math, cross-connection visibility). Census pins rotated from the phpunit red: files 1927→1938, declared reads 422→444, guard adopters 63→64, INSERT tokens 99→101 / consumed 18→20, provider stack 15→16. - **Device-M1 ship review (2026-10-02) — three follow-ups closed, forward note on the entry above.** (1) The "cross-connection visibility" claim in both the e1f1fa05 commit message and the `CastingSessionStoreRealDbTest` header was PHANTOM: every test in that file ran on one shared connection. Now genuinely proven — `testCrossConnectionFleetVisibilitySecondSocketSeesFirstsWrites` opens a second, independent `PhlixMySQLConnection` (house idiom of `StatsStorageUniqueKeyUpsertGuardTest`): writer on the pool front, reader on the second socket; legs cover cross-socket full read, real cross-socket touch, the same-second zero-changed → SELECT-1 disambiguation answering from the OTHER socket's view, and the deterministic eviction leg (delete on socket 1 → `touch()` false on socket 2). Mutation-proven: pointing the reader socket at a same-schema scratch DB reddens leg 1. (2) New `tests/Unit/Casting/CastingWiringGuardTest.php` mirrors `AuthManagerSessionTeardownWiringGuardTest`'s exact construction (real `ContainerFactory::defaultProviders()`, only `Connection` doubled): all four managers resolved FROM THE CONTAINER carry a live shared `CastingSessionStore` (mutation-proven: retiring the provider registration reddens it — the PHP-DI skips-optional-params silent-null class of defect pinned for casting). (3) `AirPlayManager`'s re-attach docblock no longer overstates the wire: of the control calls, only pause's FLUSH actually reaches the device; resume/stop are answered by `AirPlaySession`'s private RECORD/TEARDOWN wire stubs, now cited as such. Census: files 1942→1943 (the one new test file), guard adopters unchanged at 65 (the cross-connection proof joined the existing real-DB file). - **NAT-PMP is now a full RFC 6886 citizen: the two documented gaps — §3.3 lease - **NAT-PMP is now a full RFC 6886 citizen: the two documented gaps — §3.3 lease renewal and the §3.2.1 `224.0.0.1:5350` announcement listener — are closed by a dedicated resident worker (owner decision executing the `TODO(arch)` left open by c11bd4ac/a34f698d).** `src/Network/NatPmpMaintenance.php` is the pure decision core: `plan()` decides renew/skip/wait from the persisted state (half-lease `mapping_renew_at` deadline, `mapping_retry_at` backoff gate — enabled + `method=natpmp` + a real deadline are all required, so `disable()` nulls stop all work and a deleted mapping is never resurrected), `parseAnnouncement()` accepts only the §3.2.1 gratuitous address-reply shape (version 0, opcode 128, result 0), `detectChange()` treats an announced-IP mismatch **or** an SSSoE regression (gateway reboot clock reset) as "mappings are gone", and `nextRetryDelay()` gives the documented exponential backoff (60 s base, 900 s cap). `src/Network/NatPmpMaintenanceWorker.php` extends `Workerman\Worker` on `udp://0.0.0.0:5350` (`so_reuseport`, array-form `MCAST_JOIN_GROUP` per the SSDP house pattern and its raw-12-byte no-op trap), armed exactly once in a dedicated `count = 1` worker spawned by `start.php` §4f-bis behind the same master-side config-file gate style as §4f DLNA (malformed config fails CLOSED; effective admin overrides re-read in-fork through the container's admin-aware `PortForwardService`, so this process and the admin UI can never disagree). A 15 s one-shot boot-catchup plus a persistent 60 s tick call the new `PortForwardService::renewOnce()` — which re-issues the mapping for the previously-MAPPED external port (RFC 6886 lines 679-681), refreshes `mapping_renew_at` from the GRANTED lifetime, and parks failures behind the backoff so a silent gateway costs one bounded chain per slot, never a hot loop. Announcements are never trusted blindly: the source must equal the `gateway_ip` pinned at grant/renew time (unpinned → drop), and the handler commits the new baseline BEFORE acting, so a retransmit storm of one reboot announcement triggers exactly one full `autoConfigure()` cascade even if that cascade fails. Listener bind failure degrades to renewal-only mode with one loud log line — the worker never crashes. `scripts/port-forward.php` gains a `renew` command and a maintenance block in `status`; the renewal/reconfigure blocking legs are registered as **Exception 8** in `docs/dev/BLOCKING_IO_EXCEPTIONS.md` with measured bounds (renewal ≈6.1 s worst case against a silent gateway; 3004/3005 ms legs measured 2026-10-01). Pinned by `tests/Unit/Network/NatPmpMaintenanceTest.php` (15 decision-core tests), `tests/Unit/Network/NatPmpMaintenanceWorkerTest.php` (20 wiring/tick/announcement/degrade tests) and `tests/Unit/Network/NatPmpAnnouncementWireTest.php` (5 loopback wire tests incl. a real `224.0.0.1` join → production-listener receive). - **The `:8097` SyncPlay WebSocket now accepts the `Sec-WebSocket-Protocol: bearer, <jwt>` carrier alongside the legacy `?token=<jwt>` query (transitional dual-carrier law, mirroring phlix-hub `:8804`'s S237/S355 carrier so the estate policy WEBSOCKET_URL_QUERY_REFUSED has one wire vocabulary).** `SyncPlayAuthMiddleware::resolveHandshakeToken()` is the single SSOT: the bearer subprotocol entry (per-entry RFC 7230 marker match — `bearer-chat` is a different protocol-id) wins; the query param is the fallback while clients retire it; both carriers presenting DIFFERENT credentials is rejected pre-101 so a half-migrated client fails loudly instead of authenticating on a credential other than the one it presents. A client that offered `bearer` and passes the gate gets `Sec-WebSocket-Protocol: bearer` echoed on the 101 — the MARKER only, never the token (RFC 6455 §4.2.2; without an answer, WHATWG browsers fail an offered-subprotocol connect outright) — via the `$connection->headers` extension point Workerman appends after `onWebSocketConnect` returns; query-only clients negotiate nothing and see a byte-identical 101. Every handshake URI that reaches a log line now passes through the new `redactTokenQuery()` (`?token=[redacted]`, whole-param-name match), which closes the credential-in-logs exposure even for legacy query clients. Dev mode (no JWT secret) still allows anonymous connects but now honors the offer-gated echo, because negotiation is transport-level. Client retirement path (ui `syncplay.ts`, tizen `useSyncPlayStore`, mobile `wsEndpoint`, roku/console) is documented in `docs/dev/WEBSOCKET_AUTH_CARRIERS.md`; no client is flipped here, and the SPEC.md §8.4 CURRENT/TARGET status line awaits a doc-lane flip now that `:8097` implements the TARGET carrier. Handshake matrix, echo matrix, and log-masking proof pinned in `tests/Unit/Server/WebSocket/WsAuthenticationTest.php`. - **Error-code-first emit doctrine: the vendored @phlix/contracts registry is now law (`W2`).** `tests/Fixtures/Contracts/error-codes.json` is a byte-copy of `dist/error-codes.json` from the @phlix/contracts `v0.5.1` tag (202 codes / 37 domains) with a sidecar pin file, guarded by `tests/Unit/Contracts/ErrorCodesContractTest` on the hub's mcp-scopes vendoring pattern (generator-marker honesty, anti-vacuity floor, hardcoded-tag lockstep). The new emit law runs a POSITIONAL token scan (`tests/Support/Contracts/ErrorCodeScan`) over `src/` that inspects every expression the source places on a wire `code`/`error_code` field — `'code' =>` / `'error_code' =>` array keys, `Messages::error()` arg 0, `sendError()` arg 1, `Response::error()`/`jsonError()` arg 1 (an emitter table reflection-pinned from both sides: every listed signature is re-checked, and any NEW int-first `string $code` builder returning Response/self must register itself) — and requires every resolved literal to be a registry member; non-literal sites must appear in a live-checked both-directions whitelist carrying a per-site reason, so the list cannot fossilize. A whole-tree shape census was measured and rejected first: 174 non-registry dotted literals (config keys, codec profiles, webhook events) are not error codes, which is why positional scanning needs no deny-list. `Response::error(int $status, string $code, string $message, array $extra = [])` is the canonical `{error, code}` builder in the existing fluent style. `openapi.yaml` `Error.code` gains a closed enum — exactly the 51 codes the REST surface can emit (scanned literals and resolved class constants, plus the three declared dynamic exception-carried auth codes), pinned by `ErrorCodesOpenApiEnumContractTest` to the scan in registry order; SyncPlay WS codes live on the `syncplay_error` envelope the SyncPlay wire spec governs and are deliberately not in this document. - **The `@phlix/ui` messages seam is now wired in the `/app` SPA.** `web-ui` booted `createPhlixApp()` with no `messages` config — the config-time i18n seam was vendored but unreachable from the server's own portal (estate i18n audit finding). New `web-ui/src/i18n/` mirrors the proven Tizen client: priority explicit → `VITE_PHLIX_LOCALE` → `navigator.language` → `'en'`, BCP-47 parsing with the region-aware `pt*` → `pt_BR` rule, and a locale registry over the six estate bundles (`es`/`fr`/`de`/`it`/`pt_BR`/`ja`) that `@phlix/ui` now exports from its main entry — no vendored copy needed here, so ui stays the catalog SSOT through the pin. `'en'` ships a deliberately EMPTY override, and ui's `mergeMessages` makes an empty override identical to an absent one, so English rendering is unchanged; the locale resolves once at config time (no runtime switcher, per estate doctrine). The `@phlix/ui` pin moves from the `v0.99.4` tag tarball to the `v0.99.5` tag tarball (peels to `3017f443f33a4368cb7b94c67f47814fe0db0bff` — the commit that ships the locale bundles; pinned by release tag, not bare sha, because the estate's ui-pin-skew grading accepts tag-form pins only); the committed `public/assets/app` bundle is rebuilt with it and reproduced byte-identically by two consecutive builds on the CI-pinned node 24.20.0. New `web-ui/tests/` suite runs under `node --test` with zero new dependencies: an end-to-end proof against the real `@phlix/ui` bundle that a client override reaches rendered strings (ZZZ-TEST pin, omitted-messages default pin, es-flip pin), the resolver table, merge-semantics pins, and a registry-parity drift guard against future ui locale additions. - **Quick-connect device pairing and consent-gated client telemetry (`S518`, AD-25/AD-27).** Six route tuples join the composed table. The TV-side half of pairing: `POST /api/v1/auth/quick-connect/initiate` (public, IP rate-limited) mints an unambiguous 6-letter code plus a 256-bit secret and parks the pending pairing in the existing `oauth_state_store` under provider `quick_connect` — no new table for transient state, 10-minute uniform TTL, codes and secrets never mixed into one surface. `GET /api/v1/auth/quick-connect/{code}/status` (public, generously polled and rate-limited) answers `pending|approved|denied|expired` and nothing else; the secret-bearing half stays separate: `POST .../{code}/approve` runs behind `AuthMiddleware` (an authenticated phone, per-user keyed limiter, secret compared with `hash_equals`, `SELECT … FOR UPDATE` so a second approver can never overwrite the first), and `POST .../{code}/token` (public — the TV has no session yet) burns the approved pairing one-shot under the same row lock and mints the full access/refresh token pair, answering a uniform 404 for every failure class so the endpoint is no oracle. The telemetry half: `POST /api/v1/telemetry/heartbeat` (public — a heartbeat precedes any session) requires an explicit `consent: true` opt-in in the body before anything is parsed further, bounds the payload to instance id/version/client type/build token (no PII beyond the anonymous instance id), and lands via `INSERT … ON DUPLICATE KEY UPDATE` in the new `client_heartbeats` table (migration 106) — one row per consenting instance, so the table is bounded by fleet size and carries no history to reap; every write failure is swallowed to the log. `GET /api/v1/admin/telemetry/clients` (admin scope, `AdminMiddleware`) reads the fleet census back for the dashboard. Four new DB-backed rate-limit surfaces ride the shared `rate_limit_buckets` table. - **Request-side playback constraints (`S508`, AD-8).** `QualitySelector::selectQuality()` gains a caller-driven "profile arm" — two options layered on top of whatever device profile was resolved: `force_transcode` skips the direct-play verdict and returns a transcode, and `exclude_hevc` drops the HEVC aliases (`h265`/`hevc`/`hvc1`/`hev1`) from the profile's effective direct-play set so an HEVC source transcodes instead of direct-playing (the offered video codec omits HEVC). `MediaItemController::getPlaybackInfo()` reads the matching `?forceTranscode` / `?excludeHevc` query params (`1`/`true`/`yes`, case-insensitive) and surfaces the parsed values as an **add-only** `playback_constraints` key carrying only the params actually supplied. When neither is supplied the key is omitted entirely, so the response stays byte-identical for every pre-existing client — the same add-only discipline as `trickplay_bif_url` and `user_data`. No new route tuple: this edits the existing `/media/{id}/playback-info` response shape only. - **Samsung Tizen now has its own playback quality profile, split from the Roku lump (`S507`, AD-8).** An `X-Phlix-Device-Type: samsung-tizen` (or bare `tizen`) request previously mapped to the shared `tv-4k` bucket that Roku uses, so a Tizen panel got a generic 4K-TV verdict with no Tizen-specific intelligence. `QualitySelector` gains a dedicated `samsung-tizen` profile carrying the decoder gates the bucket was missing — H.264 level ≤ 5.2, SDR-only direct play (HDR10/HLG sources transcode and are tone-mapped rather than passed through), no anamorphic or interlaced direct play, HEVC Main-tier ≤ level 120, a VP9 bitrate + colour-range gate (previously blanket direct-played), an explicit 2-channel audio budget, a per-profile audio allow-list (the global set is still the fallback for every other profile), and per-device-tier bitrate ceilings (1080-class 20 Mbps vs UHD 50 Mbps) instead of one 50M bucket. Both `mapDeviceTypeToProfile()` tables — `TranscodeController` and `MediaItemController` — are split in lockstep and stay byte-identical (the reflection guard proves it). The gates are **opt-in per-profile keys**, so the five pre-existing profiles are byte-identical. Already-encoded behaviour is left untouched and re-asserted: DTS/TrueHD stay non-direct-playable (absent from the allow-list), the transcode 2-channel downmix, the ≤2160p / 50M caps, the codec-name direct-play gate, and AV1's exclusion. Playback-info now also marks every subtitle track `delivery: external` (a signed sidecar URL, never muxed), the signal a Tizen panel needs to stop expecting in-container rendering of ass/ssa/pgssub. No new endpoint: this edits the existing `/transcode` and `/media/{id}/playback-info` profile logic. - **Out-of-sync members are now NUDGED, never force-seeked — the decided S446 member sync policy.** The per-member periodic `playback_sync` report finally has a consumer: `SyncPlayManager::handlePlaybackSync()` stores the reporter's own position (ms, as frames have carried since S417) on the live `GroupState` and judges it with `isInSync` — the predicate S291 removed as runtime-proved dead code, revived with exactly the liveness its removal note demanded (per-member position storage plus a written policy). Per the owner ruling of 2026-09-12 the reaction is a NUDGE: one corrective frame on the existing `playback_sync` family carrying additive soft guidance (`nudge: {drift_ms, direction, suggested_rate, tolerance_ms, cooldown_ms}`) aimed at the drifting member only — never a seek; seek frames stay reserved for host commands. The emission is cooldown-bounded per member (one round-trip envelope, derived from `TimeSync::MAX_ACCEPTABLE_RTT`) and idempotent per ingest tick via an atomic slot claim; positions are worker-local live state that the S445 write-through bridge can neither fabricate nor clobber, and the guard test that pinned the predicate dead is retired in the same commit by its named replacement pair (out-of-sync report → exactly one bounded nudge; in-sync report → none). Thresholds are all derived from existing constants: the 2000ms `POSITION_TOLERANCE` window, the 300s stale-connection budget, and TimeSync's own 0.1 drift-correction factor as the rate step. - **Music artist/album detail now also resolves by name on a query parameter (`S240`).** `GET /api/v1/music/artist?name=<name>` and `GET /api/v1/music/album?name=<title>[&artist=<name>]` are additive twins of the existing `{mbid}` path detail routes — same auth group, same lookup, same response shape — with the entity name riding the query string instead of a path segment. The relay hub's path-traversal guard (by design) rejects percent-encoded slashes inside paths, which made names like "AC/DC" unreachable through `/api/v1/music/artists/{mbid}` on relayed deployments; query strings cross the bridge byte-for-byte and every request entry point percent-decodes them exactly once, so the library service receives the literal name. The historical path routes are untouched — shipped clients keep working — and the singular static segments shadow nothing on the router. - **The default (stable) plugin channel installs again — the official catalog pin rolled from `v2.3.0` to `v2.4.0` (`S420`).** The pinned `detain/phlix-plugins` tag predated the `phlix-plugin-sample-theme` entry commit, so with zero env overrides the audited default channel resolved a `plugins.json` that simply did not contain the flagship entry and every catalog-driven install of it was REFUSED while every offline mechanism stayed green. The live guarantee is now asserted, not assumed: a `@group network` test fetches the official catalog through the production fetcher at the pinned tag, requires the sample-theme release v1.0.0 entry to be present and `verified: true`, and re-hashes the codeload artifact (`github.com/…/archive/<ref>.tar.gz` — the estate's canonical byte source) at the entry's pinned ref against its `artifactSha256`; rolling the pin back to a pre-entry tag reddens it loudly. Operators who set the `PHLIX_PLUGINS_CATALOG_REF` override keep their override — only the zero-configuration default moves. - **Cross-process SyncPlay mutations are now visible to the serving WS worker without a restart — the write-through publish bridge (`S445`).** Until now the 14 HTTP workers each held a *private* `SyncPlayManager`, so a room created or joined through `POST /api/v1/syncplay/groups…` was invisible to the single WS worker's live tables on `:8097` (and its REST join/leave rails answered "Group not found" for any room the serving side owned). Per the owner ruling the **REST side owns persistence** and the **WS worker stays the sole authority on live in-memory state without ever becoming a DB reader**: each of the three controller rails is now hydrate → mutate → **persist first** (`SyncPlaySnapshotService`, with a new `loadSerialized()` read so join/leave modify the fresh snapshot base instead of a stale local table) → **publish** one NDJSON frame over a private unix socket (`var/syncplay-bridge.sock`, mode 0600 — same master, same user, no new port), which the WS worker accepts on its live Workerman event loop (`Worker::getEventLoop()->onReadable`, degrading to a poll timer) and applies via `SyncPlayManager::applyBridgeFrame()`. The frame is a clearly-separate internal channel — deliberately *not* a `Messages` type, which would make internal mutations client-spoofable on the public socket — carrying a shared-secret constant (`SyncPlayBridge::TOKEN`) as defense-in-depth behind the file permissions. Application is idempotent: per-group monotonic `issued_at_ms` stamps drop re-delivery and late frames, deletes tombstone against stale resurrection, and an upsert merged onto a live group replaces only the REST-owned facets (members, host) while preserving the WS-owned ones (media, playback, queue, chat). Loss posture is stated loud and honest: **fire-and-forget** — no ack, no replay — sound because durability already lives in the snapshot row, the WS worker has no boot hydration and a restart drops every live WebSocket connection anyway, and the REST read-modify-write re-publishes full current state on the next mutation, so a dropped frame self-heals. The publisher's single `fwrite` of a frame capped at 160 KiB is registered as **Exception 3** in `docs/dev/BLOCKING_IO_EXCEPTIONS.md` (≤250 ms bound, measured; a chunked select-retry design was proven to hang forever under `SWOOLE_HOOK_UNIX` and rejected for it). The model lives in `docs/dev/SYNCPLAY_WRITE_THROUGH_BRIDGE.md`; proof is `tests/Integration/Session/SyncPlay/SyncPlayWriteThroughBridgeTest.php` (two real managers, real MySQL, real socket — asserting the **served** `group_list` frame end to end, plus the named reddening test for REST-only mutations), the bridge unit pairs, and `scripts/syncplay-bridge-smoke.php` (two real processes over fork). `RequestDynamicPropertyCensusExecutableTest` re-pins the estate denominator 1864→1872 for the eight new PHP files and declared writes 965→969 for the AC helper's four `S427`-licensed assignments; the S415 envelope pin keeps its exact bytes (the rails now persist, and the pin harness double enumerates precisely the upsert/DELETE shapes, still throwing on anything else, publishing nowhere via `syncplay_bridge.enabled=false`). Config knobs: `syncplay_bridge.{enabled,socket_path,publish_timeout_ms}`. - **A machine-readable OpenAPI description of the entire served route surface, kept honest by a currency guard that recomposes the real routers.** `openapi.yaml` (repo root) now documents every operation `start.php` actually answers — 402 operations across 340 path templates, the union of `Application`'s 365 routes and the container-composed `WebPortalRouter`'s 48 (the +1 WebPortalRouter route is S73's `GET /api/v1/people/{personId}/photo`), with `Application` winning every method+path both expose (because `HttpHandler` dispatches `Application` first and only falls through to `WebPortalRouter` on its 404, the four shadowed divergences — the `[]` from `Application` on `GET /api/v1/libraries`, `GET /api/v1/libraries/{id}`, `GET /api/v1/media/{id}/posters` and `PUT /api/v1/media/{id}/poster` rather than `WebPortalRouter`'s gated chains — are documented exactly as served). Every operation carries an `x-phlix-middleware` chain, by class short name, in registration order. The file is **hand-maintained and never regenerated in CI** — a self-adjusting derived spec cannot go red, which is the S204 phantom-route defect this closes. `tests/Unit/Server/Contracts/OpenapiSpecCurrencyTest.php` is the other half: it drives the production container, recomposes BOTH routers live, parses the committed YAML with the same narrow line-wise reader the phlix-hub S66 guard uses, and fails on any route present in one artefact and not the other or on any middleware-chain mismatch; it also executes three planted drifts (a phantom path, a deleted served route, a re-gated chain) and asserts each is caught by name, so the guard is never a green that has not been seen red. A new pinned `openapi` CI job lints the document with `@redocly/cli@2.30.5` against `redocly.yaml` (`recommended`, with only `no-ambiguous-paths` and `no-server-example.com` disabled, each with its reason). `RequestDynamicPropertyCensusExecutableTest` re-pins its estate denominator 1851→1852 for the one new PHP file. - **`scripts/` is under static analysis in both tools — the server half of S256's residual, mirroring the hub's S248 shape.** Until now phlix-server's `scripts/` (43 first-party PHP files) was analysed by NOTHING: `phpstan.neon` carried `paths: [src]`, `phpstan-tests.neon` merely *scanned* `scripts/bootstrap_env.php` for its two function symbols, and `psalm.xml` was src-only — while the tests/ halves of the same step had already shipped. Widening `phpstan.neon` alone would have been the S128 trap in a new costume: CI's production step passed `src/` and `--level=9` on the command line, which override the config entirely, so the step is now config-driven (`phpstan analyse` with no path and no level, the hub's shape), `phpstan.neon` and `phpstan.neon.dist` carry `paths: [src, scripts]` at level 9 and `psalm.xml` declares both directories at its measured level 5. Both production configs additionally load the single drift-tested `Swoole\Coroutine\WaitGroup` declaration (PHPStan `scanFiles`, Psalm `stubs`) because `scripts/bench/coroutine_bench.php` constructs that userland-inside-the-binary class behind an `extension_loaded` guard. PHPStan measured 47 findings and Psalm 10 across `scripts/`; every one was fixed at the source — no baseline, no `ignoreErrors`, no `excludePaths`, no suppressions (five of the findings were live runtime defects, listed under Fixed; the review pass on this same work added a sixth: `run-media-asset-worker.php` never initialised the `ConnectionPool` either, so even after its `FfmpegRunner` fix it could not boot — no analyser sees that class of defect). `tests/Unit/Support/StaticAnalysisScopeTest.php` now pins the exact production paths allow-lists for both tools, the `scanFiles`/`stubs` allow-lists, the byte-identity of the two phpstan production configs, the new verbatim CI command with its forbidden CLI overrides, a scripts-corpus floor, and a scripts-drop mutant on every corpus pin in its negative fuzz. Corpus before/after: PHPStan production 772 → 815 files (`[OK]`, both with and without ext-swoole — CI's no-swoole condition reproduced via a private ini scan dir), Psalm production 772 → 815 files (`No errors found!`, venue: the s306srv docker mirror of CI's php 8.3.33 + full extension set; the s444 image cannot boot this repo's platform_check), tests gates unchanged and re-green. - **Cast and crew profile photos are now cached once per person, shared across every item (`S72`).** A person who appears in N titles used to leave N remote `image.tmdb.org/w185` profile URLs in the clients' hands. Enrichment now localizes each TMDB profile picture exactly once under a flat person key — `people-{tmdbPersonId}` (sha1 of the TMDB path when the id is absent) — through the generic image service extracted in S71, so the same actor across a whole library costs one download, not one per film. The key is deliberately flat and id-driven, not nested and not item-derived: `ArtworkStorage` and the existing `/api/v1/artwork/{key}` serve route already accept any `[A-Za-z0-9-]` key, so no storage, serving, or validation surface had to widen — the served `w185` variant is inside the existing size gate. `TmdbProvider` and the episode resolvers now carry TMDB person ids through the cast/crew rows (the shaper still whitelists the top-level cast/crew response blocks to `{name, role|job, profile_url}` and re-mints each stored signature per response — the raw detail-metadata passthrough is pre-existing estate behavior), the matcher rewrites each localized profile to a signed artwork URL at the persist choke point, and everything stays best-effort: a failed person download logs and keeps the remote URL, the operator artwork-download policy disables the whole path, and non-TMDB profile URLs are never touched. Eager backdrop download intentionally did not ride along (it needs a serving-side variant discrimination that is the next step's scope), and no new remote-fetch endpoint surface was opened. Two guard tests pin the single-fetch AC — one at the matcher call boundary (exact `downloadAndStore` call set across three items), one on real disk across two storage instances — and the executed-census estate count moved 1849→1851 with its mandated in-commit re-pin. - **Artwork serving became lazy resize-then-cache, and every artwork fetch became two-layer gated (`S73`).** A request for a registered width variant that was never downloaded used to answer a flat 404, and the download pipeline only ever produced the widths it fetched — so a person or backdrop cached at one size was unserveable at every other until a fresh remote fetch happened. A miss on the serve path now resizes locally from the stored original, writes the new ladder variant atomically, and serves it like any prefetched file; a miss with no source on disk still 404s with the same body, and the serve path never touches the network. The new `GET /api/v1/people/{personId}/photo?w=` route serves the person-keyed cache from the previous step through that same byte responder, with the whole conditional-GET block — stat, strong ETag, Last-Modified, If-None-Match taking authority over If-Modified-Since, bodiless 304s under a one-year immutable cache header — extracted verbatim so artwork, avatars and people photos keep byte-identical caching semantics in one implementation. Size validation runs before any path lookup, the width ladder was deliberately not widened (every serveable people width is inside the existing gate), and anonymous callers must carry a signed URL for the person resource exactly as they do for artwork. On the fetch side every download now passes two layers before a socket opens: a host allowlist anchored on the provider URL shape already pinned elsewhere in the estate — exact https host, known path prefix, userinfo, subdomain-suffix, scheme-downgrade and foreign-path spoofs refused by the regex — followed by `SsrfGuard::assertPublicUrl` re-checked on every URL the code is about to fetch, including each redirect hop. Vendor automatic redirect following was removed from both transports (blocking curl runs `FOLLOWLOCATION=false` under a bounded manual loop; the async client is called with a redirect cap of zero, against its default of five silent follows), hops are resolved per RFC 3986, re-guarded per hop, and the budget is three. The acceptance tests prove the ordering rather than asserting it in prose: an unallowlisted host throws with the DNS resolver's call counter still at zero, and a redirect chain aimed at link-local, private, or foreign hosts stops at the hop that pointed there — deleting either guard layer, the per-hop re-check, or the async redirect cap turns those tests red. The router wire-path manifests and the executed estate census moved inside the same commit, per their in-file protocols. - **The server test suite now runs in parallel lanes with the same evidence as serial (`S457`).** The `test` job's single serial PHPUnit invocation became three lanes: the Unit suite (no database) and the Integration suite run under paraunit at parallel 8, the E2E suite keeps a serial tail step because parallel E2E contends for the hardware encoder and the accelerator probe flakes under load. Integration workers no longer share one schema — `tests/bootstrap.php` gained an opt-in slot seam (when shard count is set, each worker flock-claims one of `phlix_test_w1..wN`, cloned from the migrated template by `scripts/parallel-test-db.sh`), because tests that assert global row counts corrupt each other on a shared database. Every parallel child gets a private TMPDIR through a PATH shim (`sys_get_temp_dir()` binds at process start; a later `putenv` is provably inert), which is what keeps the zero-residue census meaningful per worker. The assertion-escape guard writes per-worker report files that the CI check now globs and unions, fixing a latent last-writer-wins clobber of its own evidence under parallel runs. Artifacts are reconstituted to the old contracts: per-child JUnit logs merge into `junit.xml` (the browser gate still sees one full-run report with every testcase counted once — duplicate class detection included), and coverage is merged at the php-code-coverage OBJECT level — never by Clover arithmetic — into a `coverage.xml` that is byte-format-identical and metrically exact, so the threshold gate, codecov and Codacy consume it unchanged. The wrapper treats paraunit-reported ERRORS/FAILURES as fatal (paraunit's exit code alone was not trustworthy enough to build on); recorded PHP warnings are printed but not fatal, because a meaningful fraction of them are the failure paths under test (`@fsockopen` against unroutable hosts, corrupt-fixture `@getimagesize`, Monolog's lazy-stream stat before first write) — the genuine test-debt warnings in that set (unconditional unlinks of never-opened or already-moved paths across the auth, installer, relay, scanner and logger suites) were fixed at the source, guarded by the same commit. Local developers keep the old single-process path; every seam is opt-in. `tests/Unit/Support/ParallelTestWiringTest.php` executes the merge scripts' failure modes and pins the workflow shape, and the executed-census estate count moved 1845→1848 with its mandated in-commit re-pin. - **CI now proves the PHP extension contract instead of naming it (`S304`).** Every server-source job ran `Setup PHP` with an `extensions:` list, but naming is not a gate: `shivammathur/setup-php` defaults `fail_fast` to false, so an extension it cannot install is a red cross in the log and a GREEN step — and two of the listed names (`sodium`, guarding the hub enrollment JWT and the Trakt token-at-rest cipher; `hash`, guarding signed media URLs and JWTs) had no executed check anywhere. Each job that loads server source now runs a new `scripts/assert-php-extensions.php` step that consumes the S314 derived contract (one list, never re-derived), fails the job naming any absent extension, and refuses to trust itself unless its own absence-detector demonstrably detects a guaranteed-absent sentinel as absent — the no-op-gate class this repo has been burned by repeatedly. The two composer-only jobs are annotated with why the contract does not apply to them. All `setup-php` actions are now pinned to a full commit SHA (v2.37.2) instead of the floating `@v2` tag, so the toolchain that decides "which extensions exist" can no longer change underneath a green run. - **The served SPA bundle is now gated against its source (`S253`, server half).** The Web UI workflow ran `npm ci && npm run build` but never compared the result with the committed `public/assets/app/` tree, and its `paths:` filter ignored that tree entirely — so a commit that edited `web-ui/src` or bumped the `@phlix/ui` pin without rebuilding stayed green, and a commit that touched only the bundle ran no job at all. The job now fails when the working tree is dirty under `public/assets/app/` after the build, fails on emitted-but-uncommitted files (which `git diff` alone is blind to), prints the number of tracked files it compared, and runs on bundle-only commits too. Node is pinned to the exact runner minor the committed bundle was built with, because a byte-exact gate is only honest when the runner version is fixed — the hazard is not hypothetical: the pin bump to v0.99.1 landed without a rebuild, and this PR's rebuild is what its own new gate demands. - **Every server runtime image now publishes a deterministic immutable tag alongside its mutable one (`S474`).** The `docker` workflow pushed only `latest`, `intel` and `nvidia` — the metadata action computed sha/semver tags but only its labels output was ever consumed, and its bare-sha form could not distinguish the three legs anyway — so the anonymous registry listing held no pinned tag at all and consumers (the Helm chart's appVersion fallback, the example compose files) pointed at a tag that had never existed or rode the mutable one. Each leg now also pushes `<full-commit-sha>-<variant>` via a computed explicit tag list following the base job's own precedent, so the three legs can never collide last-write-wins and mutable publishing is unchanged. `tests/Unit/Support/ ServerRuntimeImmutableTagsWiringTest.php` pins the wiring (with mutation negative controls); the registry-side proof is the anonymous tags listing after the next master run. The release workflow's dead image env block — zero references, implying a publish step it does not perform — is gone with it. - **Generic keyed image service extracted from the poster pipeline (`S71`).** New `src/Media/Storage/ImageResizer.php` carries the validation, width-variant resize and temp-then-rename atomic-write guarantees that previously lived inside `ArtworkStorage` — moved verbatim — but addressed by a flat, media-item-agnostic target KEY (a UUID, `people-42`, or a content hash; anything matching the exact `[a-zA-Z0-9-]+` gate, refused before any filesystem call otherwise) under a configurable base directory, so backdrops and people photos (S72/S73) can reuse the same guarantees without a second resize implementation. `storeBytes()` is the verbatim-bytes path for formats that must not be re-encoded (the transparency-safe title-logo PNG). `ArtworkStorage` keeps its entire public API and behavior: it now delegates internally, its item-id gate still rejects exactly the pre-extraction id set with the historical message, and its TMDB path-fragment download seam is deliberately NOT widened to arbitrary URLs — that is the SSRF surface owed to S73's allowlist. `AvatarStorage` and `PhotoController::generateThumbnail` remain separate consolidations (S456). The service ships with 47 new unit executions covering flat-key resizing, exact exception wording on every unusable key/filename shape, and mutation-proven spy pins on each delegation seam. No DB migration, no config change, no new dependencies. - **CI-pinned composed-route inventory (server only) (`S64`).** A real-DI in-process harness (`tests/Unit/Server/Core/ComposedRouteInventoryTest.php`) composes `Application::loadRoutes()` — the primary production route surface, built from `ContainerFactory::defaultProviders()` with only the MySQL `Workerman\MySQL\Connection` doubled (a `createMock`, so no socket opens) — with no DB, no workers and no real network, then asserts the committed inventory (`tests/Fixtures/Routes/composed-route-inventory.json`) in BOTH directions (`array_diff` missing + extra, then `assertSame`), pinning **365** composed routes (GET 192 · POST 124 · DELETE 26 · PUT 22 · PATCH 1) for the server. The container-built `WebPortalRouter` 404-fallback surface is separate, is not produced by `loadRoutes()`, and is not pinned by this gate. Two control proofs make the gate honest rather than self-satisfying: a **planted-drift** test registers a throwaway in-memory route, reddens the diff, and proves the gate greens again on a fresh composition (nothing is persisted); an **unbound-stub** control builds `Application` over a container whose `get()` throws, and the exact message `unbound: Phlix\Server\Http\Controllers\AuthController` proves the real PHP-DI container — not a hand-wire fallback — is what composes the table. The inventory token is code-resident (a `const` in the harness). Tests and a JSON fixture only: no routes added or changed, no `src/`, no migrations, no behavior change — the hub half of the original step stays CLOSED (its bijection is already enforced by `OpenApiSpecMatchesRouterTest`). - **CLI user/admin management (`S61`).** Seven new `bin/phlix` commands mirroring `user:reset-password`: `user:list [--status=]`, `user:create {username} --email= --password=`, `user:promote {user} [--revoke]`, `user:delete {user} [--force]` and the server-only status-flow trio `user:approve`/`user:disable`/`user:reject`. Every one takes `--json`, emitting the shared `{"ok":true,"data":[...]}` / `{"ok":false,"error":"..."}` envelope from a new `Phlix\Console\Commands\Concerns\JsonOutput` trait. `user:list` redacts the `SELECT *` secret columns (`password_hash`, `password_reset_token`, `provider_data`) before output. A repository-level last-admin guard (`UserRepository::countAdmins()`/`isLastAdmin()`, mirroring the admin controller's predicate) refuses demoting/disabling/deleting the final administrator, and `--force` is a confirmation bypass only — never a guard bypass. Commands are registered explicitly in `bin/phlix` behind a lazy `UserRepository` factory, so `php bin/phlix list` still opens no database. No HTTP routes, no migrations. - **Embedded-tag writer — destructive in-file tag writing, strictly opt-in and default OFF (S89).** The second and riskier writer for the S87 write-back plumbing: the built-in `Phlix\Media\Metadata\Writer\EmbeddedMetadataWriter` implements `MetadataWriterInterface` and writes canonical metadata INTO the media file — `getid3_writetags` for audio (MP3/FLAC/OGG/M4A/…), and an `ffmpeg -map 0 -c copy -metadata …` remux for MP4/MKV that rewrites only the metadata container and leaves the audio/video streams byte-identical. It is default-off at two layers: the S87 per-library enqueue gate (`libraries.options.metadataWrite.enabled`) still has to be on for a job to be queued at all, and on top of that this writer re-checks its OWN dedicated global setting (`metadata.embedded_write_enabled`, resolved by the new `Phlix\Media\Metadata\EmbeddedWritePolicy`, shipped default FALSE) before a single byte is touched — so enabling the safe sidecar writer can never implicitly start rewriting media files. Three safety rails, each proven by tests: (1) **atomic-rename discipline** — every mutation stages a unique sibling temp file in the media directory and only then `rename()`s over the original, so a remux interrupted at any point (a non-zero exit, a signal kill, a failed publish) leaves the ORIGINAL bytes intact; `getid3_writetags` rewrites in place, so the original is never handed to it — only a copy. (The stage carries deliberately NO media extension — invisible to a concurrent scan and matched by the shipped `scanner.ignore_patterns` `.tmp` rule — so the muxer is PINNED on the command line instead (`-f mp4`/`-f matroska`), and the stage is created exclusively (`fopen 'xb'`); a symlink at the media path is refused rather than dereferenced. The extension-less + pinned-muxer shape was found against the real binary, not a fake runner.) (2) **overwrite policy** — when a file already carries content embedded tags, the existing `MetadataOverwritePolicy` is consulted; a deny is a normal return with a logged reason, never a throw (ruling R1). (3) **per-file curation** — a concrete predicate runs IN FRONT of the global policy: an existing stem or Kodi-convention (`movie.nfo`/`tvshow.nfo`/`episode.nfo`) sidecar that does NOT carry the sidecar writer's generator marker is treated as operator-curated and blocks the destructive write (ruling R2); an unreadable sidecar also blocks it, because the writer must not clobber what it cannot prove is machine-made. A pre-flight refuses a degenerate/empty media dir, a missing file, or an unwritable directory via the named `EmbeddedWriteFailedException` (item id + path + reason), which the worker's per-writer `catch` surfaces as a warning. External process calls run through a small `ExternalCommandRunnerInterface` seam (production: `ExecExternalCommandRunner`, a blocking `exec()` inside the managed fork per the S87 AC-2 contract) so every interruption exit path is deterministically testable. Registration rides the SAME `MetadataWriterRegistry` DI seam S88 established, ordered AFTER the sidecar writer within a drain (ruling R4); zero new routes, zero migrations. Known limits, measured not implied: in the combined sidecar→embedded drain the sidecar re-stamps the stem NFO with its marker first, so the curation predicate there guards the Kodi-convention names and a not-rewritten stem; a hand-edited NFO that still carries the marker reads as machine-made; and `metadata.embedded_write_enabled` has no admin-UI surface until `detain/phlix-shared`'s `server-settings.schema.json` declares the key. - **Sidecar metadata writer — NFO + poster/fanart next to the media file (S88).** The first writer for the S87 write-back plumbing: the built-in `Phlix\Media\Metadata\Writer\SidecarWriter` implements `MetadataWriterInterface` and writes `<basename>.nfo` (XBMC/Kodi XML), `poster.jpg` and `fanart.jpg` beside the media file — non-destructive (the media file itself is never touched; embedded tags are S89) and human-visible. Every sidecar lands atomically (temp file + rename), so a failure mid-write leaves the previous on-disk bytes intact, and re-writing the same item is byte-identical. The emitted NFO round-trips through the existing `LocalNfoProvider` reader (pinned by tests in both directions), and discovered image sidecars are exactly the ones its `findLocalImages()` poster/backdrop patterns already recognise. Ruling R1 registers it via the `MetadataWriterRegistry` DI definition in `MediaServicesProvider`, so every process that builds the container — the `metadata-write` worker fork and any admin/status consumer — sees the writer; the `PluginLoader` capability arm keeps its plugin-only semantics. The `is_writable()` pre-flight throws the named `SidecarNotWritableException` carrying item id + target directory + remediation hint (missing dir vs. unwritable dir, the latter citing the systemd `ReadWritePaths` lesson from the `/var/artwork` sandbox incident); the worker's per-writer `catch (Throwable)` turns it into a warning log line — the operator-visible status. Image sidecars are opportunistic and jailed: poster bytes come from the server's own artwork cache, and a `poster_path`/`backdrop_path` reference is copied only when it resolves inside the item's own media directory — provider-controlled remote paths and absolute paths elsewhere on disk never become file reads. Per-library opt-in stays exactly as S87 shipped it (`libraries.options.metadataWrite.enabled`, default OFF). - **Metadata write-back plumbing — queue + capability arm, zero writers yet (S87).** The first head-step of the S87→S88→S89 arc: a plugin can now declare the disk write-back capability by implementing the new `Phlix\Media\Metadata\Writer\MetadataWriterInterface` (`supports(type)` / `write(item, canonicalMetadata, mediaDir)`), and `PluginLoader::enable()` wires it into the process-scoped `MetadataWriterRegistry` — the fourth arm of the typed-capability pattern after the metadata source, the F3 subtitle source and the S84 theme source, deregistered on `disable()` for a leak-free cycle. The scan side is enqueue-only: `MediaScanner::processFile()` drops one lightweight `MetadataWriteJob` file per finalized item into the new `MetadataWriteJobStore` (a `CollectionJobStore`-style bounded, idempotent, lazily-minted file queue — no `/tmp` residue at construction, S439 census) **when the library opts in** via the existing `libraries.options` JSON column: `options.metadataWrite.enabled`, resolved by the new `LibraryRow::metadataWriteEnabled()`. That gate defaults to OFF (the inverse of S33's absent-means-on `autoCollections`): writing metadata back mutates the operator's media directory, so it is opt-in per library. No blocking filesystem I/O ever happens inline in the scan path — the new `MetadataWriteWorker` managed process (config `metadata-write`, spawned by `start.php`, which also re-runs `PluginLoader::bootstrapEnabled()` inside that fork because plugin registries are per-process resident state) is the ONLY place a writer executes, and with no writers shipped yet every drain is a documented no-op. S88 adds the sidecar (NFO/poster/fanart) writer; S89 adds the embedded-tag writer on top of the existing `MetadataOverwritePolicy`. - **`POST /api/v1/admin/updates/check` — the "check now" trigger the admin UI was missing (S273).** `GET /api/v1/admin/updates/status` only ever serialised the cached marker result, so the S78 button was honestly labelled "Check update status" because nothing could trigger a check. This adds the trigger: the handler calls the new `CoreUpdateCheckService::checkNow()` — which dispatches the marker fetch through the existing non-blocking `VersionMarkerFetcherInterface` (explicit `updates.timeout_seconds` socket timeout, default 10 s) and answers `202` without waiting, because an HTTP handler in a resident Workerman/Swoole worker must never block the event loop on a third-party host. Every failure path leaves the previously cached status INTACT (the service records the error text beside the surviving version, never blanks it); a synchronously-throwing transport becomes a `503` instead of an unhandled error in the worker. Unlike the periodic poll, the explicit trigger ignores the `updates.check_enabled` toggle — that switch governs background cadence, not an operator pressing a button; the response payload still reports the toggle truthfully. Registered in the `[AdminMiddleware]` group with the controller's own defence-in-depth gate; the 365th tuple landed in `ApplicationRouterWirePathGuardTest::ROUTE_MANIFEST` in the same commit. Phlix-ui relabel follows in the same program (cs#26 → ui re-vendor). - **`tests/` under Psalm at the measured level 5 (S306 server half, hub precedent #274).** Psalm 6 admits one `errorLevel` per config, so `tests/` gets its own shipped `psalm-tests.xml` (header records the measured ladder L1 4236 → L5 290 → L8 79, all on Psalm 6.5.0) and `coding-standards.yml`'s Psalm job gains an assert-guard plus a second step, `./vendor/bin/psalm -c psalm-tests.xml --show-info=false --no-progress`. `psalm.xml` stays src-only untouched. All 290 L5 findings were fixed at the source — except one written-why exclusion (`tests/codeception/acceptance/SyncPlayCest.php`: Codeception assembles actor methods at codecept-build time, and Psalm — unlike PHPStan — only honours the hand-written `@method` tags when the class declares `__call`, which no committed `SyncPlayTester` does), and the swoole-bundled `Swoole\Coroutine\WaitGroup` now enters Psalm's view through `<stubs>` on the existing drift-tested `phpstan-stubs/Swoole/Coroutine/WaitGroup.stub`, the same file PHPStan scans — one declaration, two analysers. `tests/Unit/Support/StaticAnalysisScopeTest.php` pins both CI command lines verbatim, both configs' directory sets as exact allow-lists, the exclusion union, the stub, the ladder evidence, the absence of `ignoreErrors`/`IssueHandler`/baseline, and a negative fuzz proving each pin bites when `tests/` silently leaves scope. No new `.php` files, so the census stays 1789. - **TMDB collection-sync job queue (S215).** `CollectionJob` + `CollectionJobStore` + `CollectionWorker` (`src/Media/`) mirror the SV-2.9 similarity trio: a file-based queue the scan path enqueues into and a supervised consumer that drains it (`config/collection_jobs.php`, `config/managed_workers.php` + `config/process.php` 'collection' entry, `scripts/run-collection-worker.php`). The store mints its queue directory lazily on first enqueue — never at construction — so resolving the DI factory leaves zero `/tmp/phlix_*` residue (S439 census). - **Test-suite zero-residue census (S439, finish-S167).** audit31 measured 212 leftover `/tmp/phlix_*` entries after an isolated full-suite run at `3bd64b84`; every offender site now carries a teardown, `BackupManager::createBackup()` cleans its temp dir on the success path too (moved to `finally`), and a new `ZeroResidueCensusExtension` registered from `phpunit.xml` snapshots the temp dir before execution and fails the run — by name — if the suite leaves any `phlix_*` entry behind. Wiring pinned by `tests/Unit/Media/ZeroResidueCensusTest.php` (S431 denominator re-pinned 1773→1775). - **Coroutine-socket construction guards (S434).** S207 measured `new \Swoole\Coroutine\Socket(...)` SIGSEGVing the worker INSIDE the constructor — through `catch (\Throwable)` — on an invalid socket type and on a genuine `socket(2)` EMFILE; a signal is not throwable, so the fix is prevention. Every construction in `src/Network/` (5 sites, 4 classes) now routes through `Phlix\Network\CoroutineSocketGuard::create()`, the only raw construction allowed in `src/`, which refuses those two entry states beforehand as a typed `CoroutineSocketConstructionRefused` carrying a `CoroutineSocketFault` (S169 `PortProbeOutcome` shape) — domains/types are membership- checked, and soft-`RLIMIT_NOFILE` headroom below the margin fails closed; each site's existing S197 `catch (\Throwable)` turns the refusal into its documented blocking-fallback degradation, so healthy paths are behaviour-unchanged. Re-measured on the actual prod base image (ghcr.io/detain/phlix-base, PHP 8.3.33-alpine, swoole 6.2.1): both S207 arms now throw catchable `Swoole\Coroutine\Socket\Exception` instead of faulting — honest non-reproduction on the current prod build, recorded in the test; the guard stands because the fault lived in the vendor binary. Census, refusal taxonomy and the never-catches law are pinned by runtime tokenization in `tests/Unit/Network/CoroutineSocketGuardTest.php` (S431 denominator re-pinned 1764→1768). - **Request dynamic-property reachability guard (S427).** `Phlix\Server\Http\Request` now pairs `__get()`/`__isset()` so an UNGUARDED read of an undeclared property throws a named `LogicException` instead of silently answering a PHP-warning null. The posture was chosen from a tokenized census of the whole repo (1,756 PHP files): 331 property reads on typed Request roots, every one on a declared member; zero dynamic writes; zero dynamic-name accesses — so the throw can only ever fire on a would-be S271 bug shape. Guarded reads (`$request->jsonBody ?? []`, `isset()`, `empty()`) keep their exact pre-guard empty-default semantics because PHP consults `__isset()` before `__get()` — which is also why both S271 mutation arms in `BackupControllerBodyPersistenceTest` fail identically before and after this change (re-run on this tip). Census and static-analysis blindness documented in the `Request::__get` docblock; every pinned shape in `tests/Unit/Server/Http/RequestDynamicPropertyGuardTest.php`. - **Executable S427 census (S431).** `tests/Unit/Server/Http/RequestDynamicPropertyCensusExecutableTest.php` re-tokenizes the whole estate at run time and pins the five census numbers as class constants (per the S342 `WriteResultAdoptionGuardTest` denominator pattern) instead of leaving them as prose: file count, declared-read and declared-write denominators, and the two posture zeros (dynamic-name reads/writes on Request roots) — a planted dynamic read now reddens naming its `file:line`. Runtime re-measurement drifts against the S427 prose (1,756→1,760 files as the estate grew; 331→391 reads and 1,037→940 writes because the lane's unpublished scan rules are arithmetically unreproducible — its write number matches plain writes plus the 97 offset-write sites while its read number does not reconcile with the same split); every qualitative claim holds — all counted sites are on declared members and all three zeros measure zero. The pinned numbers govern from here; legitimate landscape changes re-pin the two denominators in the same commit, the zeros are posture and are fixed in source. - **Self-service profiles: end-user CRUD / PIN / switch / avatar (S81).** A logged-in user can now manage their own profiles through `ProfilesController` (auth-only group, never `/api/v1/admin`): `GET|POST /api/v1/profiles`, `GET|PUT|DELETE /api/v1/profiles/{id}`, `POST|DELETE /api/v1/profiles/{id}/pin`, `POST /api/v1/profiles/{id}/pin/verify`, `POST /api/v1/profiles/{id}/switch`, `POST /api/v1/profiles/{id}/avatar`. Ownership runs through `ProfileAccessPolicy::canManageProfile()` and refusals answer **404, never 403** (no existence oracle). All five S81 blockers closed, each pinned: (1) the rating cap is no longer account-scoped — `getActiveProfile()`/`getActiveRatingFilter()` take an optional session `profileId` (ownership re-derived; `null` keeps today's behaviour) and `RatingGate::resolveFilterForUser()` defaults it from `RequestContext::getProfileId()` (the S80 claim) — a Kid session on a switched profile now gets the Kid's cap, pinned on real rows incl. the foreign-claim refusal beside its succeeding control; (2) `verifyPin()` **fails closed** (a PIN-less profile is not a pass) with a new `hasPin()` distinction (409 `profile.no_pin` vs 403 `profile.pin_mismatch`), behind a new DB-backed `rate_limiter.pin_verify` profile (5 / 300 s, per-profile key) — the unlimited-attempt oracle is gone; (3) the switch endpoint calls `switchProfile()` (its first caller) and the clear-then-set is now transactional, leaving exactly one active row (`COUNT = 1` asserted on the real table); (4) the switch RE-MINTS tokens via `AuthManager::buildAuthResponse()` so the session moves immediately, not at token expiry; (5) signup creates a first profile ('Main') on BOTH entry points — `AuthManager::register()` inside the existing transaction (active accounts only; pending heals via `resolveProfileIdForUser()`) and the external-provider create path (`UserRepository::findOrCreateByExternalId()` gained a `created` out-param so only the INSERT path mints a profile). `PUT` refuses `is_active` (400 `profile.use_switch` — activation is switch-only, closing the two-active hole `update()` could otherwise re-open) and `DELETE` refuses the last profile (409). No DB migration (`user_profiles.avatar_url` already exists); no tag; new routes registered in `ROUTE_MANIFEST` (364 entries) and pinned. - **The admin-maintenance route surface is now mirrored against phlix-ui in BOTH directions (S330).** S78's In-scope list named eight Tasks-page sections and only four shipped; an AC narrower than its own In-scope list is a mis-specified step, and three of the four missing sections (scan-all, recompute-similarity, newsletter send-now) were owned by no step. This entry records the three-way decision for each, then closes the drift class with a guard. **The decision, per action by name — all three CLOSED, not silently dropped:** - **scan-all — CLOSED.** No server-wide scan service exists. Per-library scan (`POST /api/v1/libraries/{id}/scan`, the Libraries page) is the bounded, safe path; a server-wide scan would be an unbounded whole-vault filesystem walk (the S116 filesystem-work-on-the-request-path concern; even a single music library scan runs 4 h 09 m before its first durable write, the measured 21600 s floor) and would monopolise the count=1 maintenance queue worker for the duration. The admin Tasks page keeps its honest note explaining the absence. - **recompute-similarity — CLOSED.** Similarity is already computed per-item on scan by the live `SimilarityWorker` managed worker (`config/managed_workers.php` → `'similarity'`, SV-2.9 enqueues one job per new item, candidate set bounded per library). A server-wide recompute would enqueue the entire catalogue into an O(N²)-per-library background pipeline — unbounded, redundant with the per-scan compute, and not wanted. The admin Tasks page gains an honest note explaining the absence. - **newsletter send-now — CLOSED.** The newsletter is a deliberately scheduled weekly blast (`Application::startBackgroundTimers()` → `startNewsletterTimerIfEnabled()`, day/hour from config); a send-now would fire an unscheduled email to every user, and `NewsletterSender` is not DI-wired (constructed manually in `Application.php`), so a maintenance-queue path would duplicate its construction for an action the schedule already provides. Not wanted. The admin Tasks page gains an honest note explaining the absence. No task name, route or `MaintenanceTask` vocabulary entry changes — the existing queued/sync tasks stay the five-task catalogue and `MaintenanceTaskCoverageTest`'s count is unchanged. What ships instead is `AdminMaintenanceRoutesUiMirrorGuardTest`, the S348-shaped cross-repo pin for the ADMIN maintenance surface: it parses the production route table (reflected `Application`, MySQL doubled, `ContainerFactory::defaultProviders()`), extracts the eight `/api/v1/admin/maintenance/*` routes (three GET reads incl. the two-segment `jobs/{id}` + five POST actions) and the five POST action suffixes, and asserts each equals its phlix-ui `AdminMaintenanceApi` mirror (`MAINTENANCE_TASK_NAMES` for the suffixes, `MAINTENANCE_ENDPOINTS` plus the derived `jobs/{id}` template for the full path set) in BOTH directions — `missing` (UI declares, server does not register) and `extra` (server registers, UI has no entry) each asserted empty with the compared denominators printed and an anti-vacuity floor. Both planted mutations were shown red and removed (a dummy `/maintenance/scan-all` route and a dummy UI `scan-all` name). No S348 collision: that guard covers only the library-maintenance surface (`/api/v1/libraries/{id}/<suffix>` → `LibraryController`); this one covers `/api/v1/admin/maintenance/*` → `MaintenanceController`. - **The scan-job type allowlist is pinned to the migration ENUM, and the failure message names phlix-ui's mirror (S341).** New `ScanJobsEnumMigrationTest` parses migration `101_library_scan_jobs_media_assets_type.sql` — the real file, through `MigrationRunner::splitStatements()` itself, never a hand-written fixture — and asserts `ScanJobRepository::ALLOWED_TYPES` equals the 9-member ENUM in value AND ordinal order (MySQL stores ENUMs by index; an insertion in the middle re-numbers every stored row). It also fails loudly when 101 stops being the latest declarer, when the runner sees more than one statement, or when the parse yields zero members. The equality failure message names `phlix-ui`'s `SCAN_JOB_TYPES` in `src/api/admin/libraries.ts` as the mirror to update — the step exists because that UI array drifted to 8 while the DB had 9 (migration 101's ship note claimed "zero client change") and no server-side gate noticed. Demonstrated red: adding a 10th member to a scratch copy of migration 101 turns `test_allowed_types_equals_the_enum_parsed_from_the_migration` red; restoring the file returns it to green. - **The library-maintenance POST-route set is pinned to phlix-ui's `AdminLibrariesApi` mirror, in both directions (S348).** S284 shipped `POST /api/v1/libraries/{id}/regenerate-assets` and the admin UI never gained a `regenerateAssets` method — the S332 class again: a hand-written method list that grows with the LIST, not the ROUTES. New `LibraryMaintenanceRoutesUiMirrorGuardTest` reflects the PRODUCTION route table off an `Application` built from `ContainerFactory::defaultProviders()` (MySQL doubled, exactly as the S239 wire-path guard), extracts the maintenance POST-route set (`POST /api/v1/libraries/{id}/<suffix>` handled by `LibraryController` — the nine async actions the UI mirrors), and asserts it equals the mirror list in BOTH directions: `missing` (a UI method with no server route) and `extra` (a server route with no UI method) are each asserted empty, with the full expected/actual suffix denominator printed in every failure. The mirror is spelled as the phlix-ui method NAMES (`scan`, `matchMetadata`, `regenerateAssets`, …) with the kebab-case URL suffix derived mechanically; the failure message names `src/api/admin/libraries.ts` as the mirror to update, and an anti-tamper identity test stops a "fix" that deletes a mirror member from silencing the guard. Demonstrated red: planting `POST /api/v1/libraries/{id}/regenerate-thumbnails` in a scratch copy of `Application.php` turns the both-directions test red naming the extra suffix; restoring the file returns it to green. Registration-only — response envelopes, status codes and the in-handler admin gate stay pinned by S284's `LibraryRegenerateAssetsAdminGateTest` and the S272 destructive-actions gate test. - **Skipped tests are now reported BY NAME, so a skip set can be compared with `comm` instead of by count (S345).** `phpunit.xml` gains `displayDetailsOnSkippedTests="true"`, and `scripts/skipped-test-names.sh` turns any run's output — local, or a `gh run view <id> --log` — into a sorted, `comm`-ready set of `Class::method` names. **Why a count was never enough.** Measured on master run `32027697809`: the log says `Skipped: 3` and contains **zero** `There were N skipped tests:` sections, so the only published fact about the skip set was its size. A size cannot distinguish *a test started skipping* from *a conditionally-skipped test turned into a failure* from *a test disappeared* — all three move it identically, and any two of them together can leave it **unmoved**. It also drifts on its own: this repo has legitimate environment-dependent skips, and a box with no MySQL, Chromium or mysqldump reports **259** where CI reports **3**. Two audits had to record skip movement as "unproven" for exactly this reason. The **config attribute** was chosen over the `--display-skipped` CLI flag deliberately: PHPUnit is invoked from five places in this repo — twice in `.github/workflows/phpunit.yml`, twice in `.github/workflows/syncplay-e2e.yml` and once in `scripts/assertion-escape-audit.php` — plus every developer's shell, and a flag would have to be re-added to each of them and to every job added later. The attribute is read by every invocation that loads `phpunit.xml`, so a new job cannot silently ship without it. Those sites are named by FILE and their count is RE-DERIVED by `SkippedTestNameReportingTest::test_the_documented_invocation_sites_are_still_the_real_ones`; the first revision of this entry cited line numbers and its own commit moved them. ⚠ **It is not universal, and two exceptions are real.** A `--testdox` run can never name its skips: PHPUnit builds the default result printer for testdox with its `$displayDetailsOnSkippedTests` argument hardcoded `false` — the `outputIsTestDox()` branch of `Facade::createResultPrinter()`, `vendor/phpunit/phpunit/src/TextUI/Output/Facade.php:204-221` in the pinned PHPUnit 10.5.64 — so it counts them in `Skipped: N` and prints no list, with or without `--display-skipped`. `syncplay-e2e.yml` passed `--testdox`; **it no longer does** — the prettified list was cosmetic and it also broke a whole-run-log comparison by adding to the count while contributing no name. The `assertion-escape-probe` job captures PHPUnit's output into a PHP variable and never echoes it (`scripts/assertion-escape-audit.php`), so no name set is obtainable from that job either; its config is not at fault. The script reports the testdox case as exit **5** with that explanation rather than blaming `phpunit.xml`. The attribute enables **two** lists, not one — `printSkippedTestSuites()` as well as `printSkippedTests()`, because `Skipped: N` is the sum of both event kinds (`SummaryPrinter.php:106`). A skipped SUITE is named under `There were N skipped test suites:` with no `::` in its entries, and a run in which every suite skipped prints `No tests executed!` with no `Tests:` line at all. The script parses both, emitting a suite as `SUITE:<name>`, and accounts for the missing totals line instead of reporting drift. Verified against the real binary, not a hand-written fixture. It is **purely additive** — verified by running the full suite either side of the change on one seed: identical exit status (`1` both times, from a pre-existing environment-dependent failure on the measuring box), identical `Tests: 10249, Assertions: 67983, Failures: 1, Skipped: 259.`, and a diff of the two outputs that is empty apart from the new list and the `--` separator PHPUnit puts between lists. `failOnSkipped` is a separate setting and is not set here. The script refuses to be quietly empty, because a parser that matches nothing reads as a pass: it prints its denominators on stderr and exits **2** when the input is not a PHPUnit run at all, **4** when skips were counted, no detail list was printed and testdox does not account for them (i.e. the attribute went missing, or that invocation did not load `phpunit.xml` — the first of those reproduced against master's own CI log), **5** when the unnamed skips are matched one-for-one by testdox skip glyphs, **3** when the numbers do not add up and **6** when the sorted set could not be written in full. Each code names the cause its own arithmetic supports and never attributes more to a cause than that cause can carry; a wrong diagnosis is worse than none, so 5 is gated on the number of testdox SKIP lines (` ↩ `) and taken only when that number **equals** the shortfall — never on the mere presence of testdox-looking output, which would exonerate `phpunit.xml` about a run whose only fault is `phpunit.xml`. When testdox covers part of the shortfall both causes are named. When there are **more** skip glyphs than unnamed skips nothing is attributed to testdox at all: a real testdox run cannot produce a surplus (PHPUnit counts every glyph it prints in the same run's `Skipped: N`), so a surplus is evidence of foreign output in the log — `gh run view --log` carries every tool's — and it is reported as unattributable while both candidate causes stay in play. The clamp that used to trim such a surplus down to the shortfall, and so printed "Do NOT change phpunit.xml" about a config that was the cause, is **removed**: it had no legitimate input. Entries declared by a `No tests executed!` run are subtracted out before the shortfall is computed, because they have no skip count of their own to match. ⚠ It is **not** claimed that every exit is right for every input. All of the denominators are **input-wide sums**, so in a multi-run log (`gh run view <id> --log`) one run's shortfall can be paid for by another run's surplus and net out to a code that is right for the sum and wrong for both runs — measured on the shipped version: a run declaring two names but summarising one, followed by a count-only run summarising one, exits **0** with a two-name set. Two such inputs were measured in round 3 of this step's review and are closed with fixtures, and the testdox-glyph surplus is closed by deleting the branch that read it; a `--testdox` run that also skips a whole test SUITE is not closed, because a skipped suite produces no testdox glyph, and neither is exit 5's reliance on an equality of two whole-input totals (N stray glyphs beside a run that lost N names look the same as one testdox run that skipped N). The durable fix is **per-run segmentation** of the parser, which is filed as a follow-up rather than done here. Two smaller limits complete the list: a data-set label containing a literal **newline** is emitted truncated at that newline (the only shape that escapes the denominator cross-check — no provider here produces one, and the truncation is stable, so `comm` is not corrupted), and a drift in PHPUnit's list HEADER text refuses loudly but as exit **4** blaming `phpunit.xml` rather than exit 3 blaming the parser — the right refusal with the wrong cause, since a vanished header and a missing attribute are arithmetically identical. The script's header is the canonical statement: it enumerates every exit path, the input class that reaches it, and all five known limits by number, each with the input measured to reach it. `tests/Unit/Support/SkippedTestNameReportingTest.php` fails if the attribute is removed from `phpunit.xml`, if the script loses its executable bit, or if the README stops documenting the `comm` comparison — and it drives the **real** `phpunit` binary for the plain, `--testdox` and skipped-suite shapes, so a hand-written fixture can no longer diverge from what PHPUnit actually prints. It also re-derives the `--testdox` ban from every `.yml` **and** `.yaml` workflow, parsing each one and folding shell continuations first (a `.yaml` file and a wrapped command both defeated the first version of that guard), and re-derives the invocation-site count the paragraphs above claim. Both testdox glyphs have their own stray-line fixture: `✔` (attributes nothing, reported as evidence) and ` ↩ ` (attributed at most once, never exonerating), plus the surplus case — two stray ` ↩ ` lines beside a count-only run — which is RED against the removed clamp. The `✔`-only fixture is why the surplus defect survived three review rounds. - **Admin maintenance endpoints — the backend for the admin Tasks page (S77).** Eight routes on `/api/v1/admin/maintenance/*`, inside `AdminRoutes`' `AdminMiddleware` group, with an explicit in-body admin re-check in every handler (two of the five tasks are destructive, and a group's middleware is one refactor away from being lost): | Verb | Path | Mode | |------|------|------| | GET | `/api/v1/admin/maintenance/tasks` | catalogue | | GET | `/api/v1/admin/maintenance/jobs` | recent queued runs (`?limit=`, `?task=`) | | GET | `/api/v1/admin/maintenance/jobs/{id}` | one job, for polling | | POST | `/api/v1/admin/maintenance/storage-snapshot` | **queued** → `202` | | POST | `/api/v1/admin/maintenance/reap-scan-jobs` | sync → `200` | | POST | `/api/v1/admin/maintenance/reap-transcode-jobs` | sync → `200` | | POST | `/api/v1/admin/maintenance/cleanup-orphaned-stats` | sync → `200` | | POST | `/api/v1/admin/maintenance/dedupe-paths` | **queued** → `202` | **The sync/queued split is a property of the task, not of the caller.** `storage-snapshot` `shell_exec`s `du -sb` per bucket over `/vault1` and `/vault2` (which on the live host hold the whole media library) and `dedupe-paths` scans `media_items` whole with a transaction per duplicate group — running either in a Workerman HTTP worker stalls every concurrent connection on that process. Both are enqueued onto the new `maintenance_jobs` table (migration 098) and drained by a 5-second timer inside the existing `count = 1` `phlix-background-timers` fork, which is what makes "one maintenance job at a time" true. A repeat POST while the same task is pending answers `200 {created: false}` with the EXISTING job, so a double-clicked button cannot start two `du -sb` walks. **`cleanup-orphaned-stats` closes out S14's deferred cleanup action.** S14 shipped the read-side INNER JOINs so orphaned rows stopped appearing as blank dashboard entries; they were never removed. It deletes from `stats_playback_events` (both `media_item_id` and `user_id`), `stats_library_changes` and `stats_user_activity`, bounded by a per-table `LIMIT` that reports `truncated: true` rather than looping. 🚨 It **refuses outright** when `media_items` or `users` reports zero rows: `NOT EXISTS (SELECT 1 FROM media_items …)` is true for every row against an empty parent, so a fresh install or half-restored backup would otherwise delete the entire playback history in one click. **`reap-scan-jobs` imposes a six-hour FLOOR on the age it will accept**, derived from the 4 h 09 m production music scan recorded in `LibraryScanWorker::start()`. `library_scan_jobs` has no heartbeat column — `started_at` is the only age signal and it is never refreshed while a scan runs — so honouring a short request would mark a healthy scan `failed` AND make `hasActiveJobForLibrary()` report the library idle, re-opening the door to a second concurrent scan over the same files. `ScanJobRepository::reapStaleJobs()` gained an optional age bound for this; its default (null) is the unchanged boot catch-up contract, which still reaps every `running` row and must stay reachable only from `LibraryScanWorker::start()`. **`dedupe-paths` defaults to a DRY RUN.** `apply` must be strictly `true` — the string `"false"` is truthy, and this deletes media rows. The keeper-selection and merge rules moved out of `MediaDedupePathsCommand` into `Phlix\Media\Library\PathDedupeRunner` so the CLI and the task share ONE copy rather than two that drift. - **The AccessSchedule `timeToMinutes()` `intdiv` fix is now pinned by a regression test (S336).** `tests/Unit/Access/AccessScheduleTest.php` (11 tests, 80 assertions) drives the REAL call graph `timeToMinutes()` → `isActiveAt()` → `AccessScheduleService::getActiveScheduleForProfile()` line 69 against a mocked `Workerman\MySQL\Connection`, and names the boundary cases the fix was introduced for individually: the `"00:10:30"` non-multiple-of-60 example, the `fromRow()` default `end_time "23:59:59"`, second-granularity across a full minute, exact start/end inclusivity, one second before start / after end denial, and the inclusive overnight window. Red-on-revert is demonstrated, not asserted: mutating `intdiv` back to `/ 60` makes **7 of the 11 tests fail with `TypeError: Return value must be of type int, float returned`** under `strict_types=1` (the four that stay green are the `:00`-seconds boundary-pinning probes, documented as such). The prevention question — why PHPStan level 9 cannot pin this shape (it types `$int / 60` as a benevolent `(float|int)` union; verified against PHPStan 2.2.5) — is written into the `AccessSchedule.php` docblock. - **The S111 fixture-id uniqueness pin is now provable from its CSPRNG half, and the last `mt_rand()`-based test id generators are gone (S334).** `BrowseIndexUsageTest`'s S111 pin ("a pinned mt_rand seed must not reproduce a fixture id") was satisfiable by the generator's monotonic counter ALONE: freezing `bin2hex(random_bytes(9))` to a constant would have kept every assertion green while reinstating the cross-run collisions S111 removed. The generator now lives in `tests/Support/FixtureIdGenerator` so a unit-level test (`FixtureIdGeneratorTest`, 3 tests) can probe the REAL generator without MySQL; the new guard compares only the random half (positions 0-23, the 12-hex counter field at position 24 excluded), and the mutation is demonstrated red — frozen prefix → `Failed asserting that two strings are not identical` — with the counter left intact. The S111 pin in `BrowseIndexUsageTest` gains the same (2b) prefix assertion for the CI/MySQL path. `PooledConnectionConcurrencyTest::uuid()`'s 8 executable `mt_rand()` calls are converted to the same CSPRNG pattern (`random_bytes(16)` with v4/variant nibbles), so a pinned `--random-order-seed` can no longer regenerate identical PK ids across same-seed runs. - **S219 — the DLNA admin controller's production DI wiring is now pinned (test-only).** `AdminDlnaServerController::setSettingsRepository()` / `::setRestartController()` have exactly two production call sites, `Application::loadDlnaAdminRoutes()` at `Application.php:1089` and `:1102`, and **every existing test wired the controller by hand.** Replacing the first call site with a no-op left `DlnaAdminRoutesTest` + `tests/Unit/Server/Http/Controllers/Dlna` + `tests/Unit/Dlna` at `OK (310 tests, 929 assertions)`. In production that same no-op makes every DLNA start/stop answer `503 "DLNA settings store is unavailable…"`, because `applyEnabled()` short-circuits on `$this->settings === null` before anything else — the admin toggle would just stop working, silently. New `tests/Unit/Server/Core/ApplicationDlnaAdminWiringGuardTest.php` (6 tests) never constructs the wired controller. Following the S239 precedent, it reflects the `Router` a real `Application` composed from `ContainerFactory::defaultProviders()` (only the MySQL `Connection` doubled) and pulls the controller **back out of the route table**, which is where `loadDlnaAdminRoutes()` put it. Those three routes bind an already-constructed instance, not a class-string, which is exactly why the wiring has to be read off the registered object. It pins: both collaborators present on the container-composed instance; all three DLNA admin routes sharing that one instance (a per-route instance would leave two unwired); and the production CONSEQUENCE — `stop()` on the composed controller answers **409** ("already disabled", since `dlna.cds_enabled` ships false), not the unwired **503**. Because 409 and 503 are both refusals, a hand-built unwired controller sits beside it as the CONTROL, answering 503 with the unwired message — which is also the floor proving those fields genuinely can be null. The S239 300-route anti-vacuity floor is carried over so a hollowed loader or a hand-rolled container (53 routes vs 345) fails loudly instead of passing vacuously. - **S74 — the server can now tell an operator that a newer phlix-server exists.** New `Phlix\Server\Updates\` (`CoreUpdateCheckService`, `CoreUpdateCheckWorker`, `CoreUpdateStatus`, `VersionMarkerFetcherInterface`, `AsyncVersionMarkerFetcher`), a new repo-root `VERSION` marker, `config/updates.php`, and two admin endpoints: `GET /api/v1/admin/updates/status` and `PUT /api/v1/admin/updates/settings` (body `{"checkEnabled": bool}`), both inside the `AdminMiddleware`-gated group and both carrying their own in-handler admin gate. **No apply action, deliberately.** The status payload carries `updateCommand`, a copy-to-clipboard `curl … | sudo bash -s -- --update -y` one-liner. The HTTP handler never invokes git/composer/systemctl, and `testThereIsNoInlineApplyRoute` pins that closed. **The fetch is async and fork-free.** The step text prescribed `PluginCatalogService::defaultFetcher()`; that shape forks on `WorkerContext::inCoroutine()` and PHPUnit is never in a coroutine (`getCid()` is always `-1`), so the arm production takes would have been structurally untestable — the S170 defect class. `AsyncVersionMarkerFetcher` uses `workerman/http-client` in **callback mode** with BOTH `success` and `error` supplied, which keeps `Client::request()` out of its coroutine-suspending branch (vendor `Client.php:78`) entirely. Nothing blocks the worker and there is no untestable arm. **The worker has two independent arms**, armed from `Application::startBackgroundTimers()` on the count=1 `phlix-background-timers` worker: a **one-shot boot catch-up** 300 s after start, plus the **persistent daily poll**. A bare `Timer::add(86400, …)` fires only after 24 h of uninterrupted uptime and every restart resets the countdown — the identical defect already shipped twice here (empty `backups`, `PluginAutoUpdateWorker` never running). Both arms are pinned by separate tests that FIRE the scheduled callbacks rather than reading intervals. ⚠ **`VERSION` is a second source of truth for the version and must be bumped in lockstep with `Phlix\Common\Version::STRING`.** `tests/Unit/Server/Updates/VersionMarkerFileTest.php` makes drift a red test: behind the constant and no release ever announces itself; ahead of it and every install nags about a release that was never cut. - **S131 — one predicate for "did this write anything", and a measured contract to go with it.** New `Phlix\Common\Database\WriteResult::wroteNothing()` (`src/Common/Database/WriteResult.php`) promotes the predicate S96 added privately as `MusicLibraryScanner::statementWroteNothing()`, which now delegates to it. **Nothing was broken:** there are zero falsy tests on an insert result anywhere in the repo, so this closes an unguarded contract rather than a live defect. **The contract, read out of `vendor/workerman/mysql/src/Connection.php::query()` (`:1854-1869`) rather than inferred.** The client dispatches on the first *space-delimited* token of the trimmed SQL: `select`/`show` → an array, `update`/`delete`/`replace` → an `int` `rowCount()`, `insert` → `lastInsertId()` as a **string** when rows were affected and **`null`** when none were, and anything it does not recognise → `null`. A real error never returns at all — `execute()` (`:1742-1786`) rethrows. Three consequences are now documented at the helper and pinned in `tests/Unit/Common/Database/WriteResultTest.php`: (1) `false` is a value this client never produces, so the `=== false` shape at the six unguarded sites was simultaneously unreachable and blind to `null`; (2) 🔴 a SUCCESSFUL insert into a `CHAR(36)`-PK table returns the string `'0'`, which is FALSY in PHP, so `if (!$result)` reads a success as a failure; (3) the keyword match is whitespace-sensitive, so reformatting SQL into a heredoc can move a site onto the `null` arm without changing a semantic. **Applied to five of the six named sites, and deliberately NOT to the sixth.** `DbOidcStateStore`, `DbOAuth2StateStore`, `DbTraktOAuthStateStore`, `DbLastfmOAuthStateStore` (insert guards, plus their four `$deleteResult === false` guards) and `DbLoginRateLimitStore` now route through the helper. `StreamSessionService::updateStreamLimit()` does not: its `INSERT … ON DUPLICATE KEY UPDATE` returns `null` when the values are already current (measured against real MySQL 8) and the falsy string `'0'` when a row really is written — and **both are success**, so inverting the helper into that return value would report a failed update for an idempotent PUT. The site returns `true` (which is exactly what `$result !== false` always evaluated to) with the reasoning stated in place, and both shapes are pinned so the tempting "completion" reddens. **Two corrections to the inventory.** It was quoted as 11 sites; re-derived by walking every `->query()` whose verb is `INSERT`/`REPLACE` — 85 calls in `src/`, of which 74 discard the result outright — it is **12**: `ScanJobRepository::startRunningIfIdle()` landed with S151 after the count was taken, and was already correct. And `StreamSessionService::cleanupStaleStreams()` is a DELETE, not an insert: it returned a constant `1` behind a `!== false` that could never be false, contradicting its own `@return int The number of stale streams removed`. It now returns the `rowCount()`. **The doubles were fixed too, because a fix that changes what a client returns must change the double that impersonates it.** S96 proved that: deleting the `null` arm left the entire scanner suite byte-identically green because no double could produce `null`. `MusicLibraryScannerTest`'s two remaining callback doubles returned `int 1` for an INSERT — a value the client cannot produce — and now return the measured shapes (`'0'` for `media_items`, a string id elsewhere). Proof: removing the `null` arm is **15 RED** across six files, removing the `false` arm is **12 RED**, and `return !$result;` — the `'0'` trap — is **72 RED**. - **S180 — the assertion-escape PROBER now runs in CI, behind a tracked baseline.** S120 shipped two halves and wired only one. The runtime guard (the `test` job's "S120 assertion-escape guard" step) sees an escape only when the assertion actually fails and only when the failure went through `Assert::assertThat()`. The static half — `scripts/assertion-escape-audit.php --probe`, which plants a named tripwire before the first assertion of every assertion-bearing closure and decides by EXECUTION whether it can fail its test — was left out because one Integration site is a known flake, and a gate that reds intermittently gets deleted. New job `assertion-escape-probe` in `.github/workflows/phpunit.yml` runs it. **The two halves catch different things and the static one caught the case that actually occurred:** the live escape found on 2026-08-02 (`SmartPlaylistRefreshSubscriberTest`) probed `DEGRADED` while the runtime guard reported exit 0, silent. **Census, re-measured rather than quoted** (dev box, `origin/master` @ `94881d73`). The numbers in circulation disagreed and both were stale as a description of master: the planning text said 23/19/4, this file's S120 entry said 20/17/3 — correct for the 20-closure tree S120 shipped against, which is why it carries "re-run the probe rather than quoting a remembered count". Actual, with the exclusion applied: `22 site(s) probed — 19 GATES, 3 NOT-REACHED, 0 VACUOUS, 0 DEGRADED, 1 EXCLUDED`, exit 0, **identical with a live mysql:8.0.46 and with no database at all** — every probed site uses a mocked connection. Full 23-site figure before the exclusion: 19 GATES + 4 NOT-REACHED without a DB, 20 GATES + 3 NOT-REACHED with one. **The undecided count is now a checked fact, not folklore.** `tests/Support/AssertionEscape/probe-baseline.json` enumerates the three `NOT-REACHED` sites with a reason each — all three are a `$this->fail(…)` guard on a path that must not be taken, so a probe *cannot* decide them; two are S120 census sites 10 and 14. `ProbeBaseline::reconcile()` reds the gate in **both** directions: an observed `NOT-REACHED` that is not listed, AND a listed entry that has started gating or has disappeared. A list that only ever grows is how a gate becomes a permanent hole. **The flake is excluded with its measurement, not on trust.** `PooledConnectionConcurrencyTest::runChurn` probes non-deterministically — 19 `--only` runs against a live MySQL gave 18 `GATES` and 1 `NOT-REACHED`, and the same file with no tripwire at all failed 3 of 20 bare runs with `reader: pool exhausted: no idle connection available after 10 s`. It flips between `GATES` and `NOT-REACHED`, which no single baseline state can express, so *either* choice reds the gate over S137's pool defect rather than over an assertion. Decide it by hand with `--probe --only=<n>`, which deliberately ignores the exclusion list. ⚠ **ADDITIVE, NOT A REPLACEMENT — a green here is not whole-suite safety.** The enumeration is LEXICAL: 186 closures reach an assertion only through a HELPER and carry no `assert*` token, so this job sees none of them. The runtime guard covers those. Both the script header and the job's own output say so, because the failure mode to avoid is someone deleting one half on the strength of the other being green. - **`scripts/install.sh` now applies the Workerman kernel tuning, on both fresh install and `--update`.** Implements the applicable parts of the [kernel-optimization appendix](https://www.workerman.net/doc/workerman/appendices/kernel-optimization.html): - `LimitNOFILE=1048576` in the systemd unit. This is the one that mattered: systemd does **not** read `/etc/security/limits.conf`, so the daemon was running at systemd's soft default of **1024** open files — one fd per live connection plus open HLS segment files, reachable under load and surfacing as "Too many open files" with clients dropped mid-stream. Retrofitted into existing units by `--update`, only when the directive is absent. - `/etc/sysctl.d/99-phlix-net.conf` — `net.core.somaxconn=65535` (was 4096, and it silently clamps every listen backlog), `tcp_max_syn_backlog=262144`, `netdev_max_backlog=30000`, and `ip_local_port_range=16384 65535` (a *widening* — 49,152 ephemeral ports vs the 28,232 of the 32768-60999 default; the floor stops at 16384 rather than the page's 10240 so the registered-port band, which includes every port Phlix listens on, stays outside the ephemeral range). - `/etc/security/limits.d/99-phlix.conf` — nofile soft 65536 / hard 1048576 for `root` and the service user, covering login shells and hand-run CLI scripts (systemd is unaffected by this file; see `LimitNOFILE` above). Four settings from that page are deliberately **not** applied, each with the reason recorded in the generated sysctl file: `fs.file-max` and `tcp_max_tw_buckets` would *lower* modern kernel defaults, `tcp_tw_recycle` was removed from Linux in 4.12, and `nf_conntrack_max` is only meaningful behind a stateful firewall. `--uninstall` removes both drop-ins. - **A mechanical guard for the swallowed-assertion class** (S120), in two independent parts: - `tests/Support/AssertionEscape/` — a PHPUnit extension (registered in `phpunit.xml`) that reports any test where an assertion failure did not decide the outcome. The **measurement** is exact rather than heuristic: `Assert::assertThat()` emits `Test\AssertionFailed` from a `finally` **before** the exception propagates, so the event fires even when the exception is subsequently swallowed. Its limits are stated rather than papered over, in `EscapeCollector`'s docblock: - **Blind spots** — three paths throw an assertion failure without going through `assertThat()` and so emit no event: `Assert::fail()`; mock parameter/invocation rules (`Parameters.php:117` → `Constraint.php:106`); and `markTestSkipped()`/`markTestIncomplete()`, whose exceptions are also `RuntimeException` subclasses. Both of the latter two are currently harmless — mock rules are re-verified at `TestCase.php:688`, after `runTest()` returns and thus outside any swallowing callback, and a token scan finds 0 `markTest*` calls inside closures — but they are not covered. - **False positives** — a test that *deliberately* catches its own assertion failure (the usual way to test an assertion helper) is reported as a violation and fails CI. There is no opt-out, deliberately: an escape hatch would be reached for at precisely the moment a real escape is being looked at. The docblock records the non-tripping remedies instead. - `scripts/assertion-escape-audit.php` — enumerates every assertion-bearing **closure** under `tests/`, probing its **first** assertion (the unit is the closure, because being swallowed is a property of the call site; on this tree that is 20 of the 51 assert-like calls inside closures), and with `--probe` plants a tripwire and classifies each site `GATES` / `VACUOUS` / `DEGRADED` / `NOT-REACHED`. Covers `Assert::fail()` and sites that are green today. Runs PHPUnit with `--no-extensions` so it stays independent of the runtime guard. `scripts/assertion-escape-check.php` turns the extension's report into a non-zero exit and is wired into `.github/workflows/phpunit.yml` as an additive `if: always()` step. A static rule was considered and rejected on evidence: deciding whether an assertion in a callback can fail requires knowing which production frame invokes that callback — a binding PHPUnit's mock builder makes at runtime — and the one purely-lexical approximation available ("assertion-bearing closure in a test method that also has its own `try`/`catch (\Throwable)`") produces a false positive on `PooledConnectionConcurrencyTest`, where the caught message is deliberately re-surfaced by `assertSame([], $errors, …)`. - **A CLI scan is now VISIBLE in the admin UI, and can no longer strand a permanently-`running` job row** (S150/S151). `php bin/phlix library:scan <id>` previously wrote *nothing* to `library_scan_jobs`, so the admin Libraries page kept showing whatever the last web-enqueued job had left behind — measured on production during the S145 healing rescan as a red `failed` badge sitting over a healthy scan that had been repairing rows for 45 minutes. The command now opens its own `running` row up front, streams `items_found` / `items_updated` / `current_path` through the same throttled sink the background worker uses, and stamps `completed`/`failed` on every exit path — including SIGTERM/SIGINT/SIGHUP and a fatal-error shutdown. It also **refuses to start while the library already has a `queued`/`running` job**, because two scanners over one library race on every per-file find-or-create and only one of them can own the badge. The refusal is enforced *inside* the INSERT (`INSERT … SELECT … WHERE NOT EXISTS`), not by a read-then-write pair, so two invocations started in the same instant cannot both win. **`--force`** overrides it, for the one case the check cannot distinguish: a row left `running` by a `kill -9` or a power loss, which no in-process handler can ever clean up. ⚠ **Two residuals, stated rather than glossed.** `SIGKILL` cannot be trapped by anyone. And `LibraryScanWorker`'s startup reaper is unscoped and ageless, so a `phlix-server` restart during a long CLI scan marks that row `failed` while the CLI keeps running — which also makes the library look idle to the refusal check. Bounding the reaper needs per-job worker ownership, i.e. a schema change. - **`enum` / `minimum` / `maximum` are now enforced on `PUT /api/v1/admin/settings`.** The endpoint previously validated the internal *type* only; the schema's 4 `enum`, 21 `minimum` and 18 `maximum` constraints were emitted to the UI for display and never checked on write, so every bound was cosmetic and trivially bypassed with a direct PUT (`auth.signup_mode` could be set to any string, `transcoding.max_concurrent_transcodes` to `0` or `999999`). Coerced values are now validated against their own JSON-Schema property sub-schema using `justinrainbow/json-schema` (already a dependency, previously unused here), reported through the same `errors{}` map the SPA renders as inline per-field errors. The UI's "Auto-detect" accelerator option is the schema's empty-string enum member and validates directly — the narrow shim that briefly translated the SPA's `"null"` string against a JSON `null` enum member was removed once `phlix-shared` v0.24.0 swapped that member for `""` (see *Changed* below). - **`SettingsRepository::hasDefault()`**, distinguishing "the config declares this key and its value is null" from "no such config path exists" — which `getDefault()` cannot express. Backs a new regression test asserting every schema key resolves to a real config default. That test ran quarantined behind a 25-key `PENDING_SHARED_REVENDOR` list while the schema still declared unresolvable keys; the v0.24.0 re-vendor removed them all, so the constant and its companion staleness test were deleted and the assertion now runs unconditionally over all 40 keys — **43 as of the v0.25.0 re-vendor below**, still unconditionally, still with no quarantine. - **Phase 1 — `AdminSettingsController` now emits `meta` block in GET `/api/v1/admin/settings`.** Each key in `data.meta` carries per-setting metadata: `label`, `helpText`, `helpLinks`, `tier`, `group`, `enum`, `enumLabels`, `optionHelp`, `minimum`, `maximum`, `default`, `secret`, and `restart`. As defensive defaults `helpLinks` resolves to `[]` (empty array) and `tier` resolves to `'standard'` when not explicitly set in the schema. - **Phase 2 — 28 new server settings added to `schemas/server-settings.schema.json` and `config/*.php`.** Schema extended with Phase 2A–2C settings: `transcoding.preferred_accelerator`, `transcoding.include_software_fallback`, `transcoding.tone_mapping_mode`, `transcoding.prefer_hdr_output`, `transcoding.max_concurrent_transcodes`, `transcoding.transcode_timeout`, `transcoding.max_concurrent_scan_probes` (Phase 2A); `metadata.preferred_language`, `metadata.preferred_country`, `metadata.fanart_api_key` (Phase 2B); `database.pool_size`, `database.timeout`, `relay.reconnect_delay`, `relay.ping_interval`, `hls.segment_seconds`, `hls.max_concurrent_segments` (Phase 2C). All keys are optional with defensive defaults in `config/*.php`. - **Phase 3 — server tunables added to schema: segment cache limits, stale-job age, global inflight cap.** New keys: `transcoding.segment_max_inflight_global`, `transcoding.segment_cache_max_age`, `transcoding.segment_cache_max_bytes`, `transcoding.stale_job_max_age`. - **Phase 4 — subsystem killswitches: scan, plugin auto-update, marker detection, asset jobs, similarity.** New keys: `subsystem.library_scan_enabled`, `subsystem.plugin_auto_update_enabled`, `subsystem.marker_detection_enabled`, `subsystem.media_asset_jobs_enabled`, `subsystem.similarity_enabled` — each a boolean kill-switch to disable heavy background subsystems independently. - **Phase 5 — auth settings: enabled flag, rate limit override, session lifetime.** New keys: `auth.enabled`, `auth.rate_limit`, `auth.session_lifetime`. Consumed by `AuthManager` to gate auth surfaces and override per-surface rate limits. - **WS-D — media SPA pages are now reachable under `/app` (Smarty retirement pending owner verification).** `web-ui/src/main.ts` now registers routes **and** top-bar nav entries for SPA pages that shipped in `@phlix/ui` but were previously unreachable on the server: **Books** (`/app/books`, `/app/books/:id`, `/app/books/:id/read`), **Audiobooks** (`/app/audiobooks`, `/app/audiobooks/:id`, `/app/audiobooks/:id/play`), **Photos** (`/app/photo/albums`, `/app/photo/album/:id`, `/app/photo/photo/:id`, `/app/photo/slideshow`), **Search** (`/app/search`), and the **Music** sub-pages (`/app/music/artists`, `/app/music/artist/:name`, `/app/music/album/:name`, `/app/music/tracks`, `/app/music/player`), plus nav links (Music, Books, Audiobooks, Photos, Search). The passkey/WebAuthn surface is now a **Security** tab on the SPA Settings page (`/app/settings/security`) rather than a standalone page. **Update:** these `/app` pages are now verified live and the equivalent Smarty SSR templates + routes have since been **deleted** (see the Smarty-retirement entries under *Changed* / *Removed* above); the legacy page paths now 302-redirect to `/app`. - **SV-3.3 — loudness normalization + client capability negotiation.** - `config/ffmpeg.php` gains a `loudness` block (`enabled` defaults to **false**) that, when enabled, applies an EBU R128 `loudnorm` audio filter (`I`/`LRA`/`TP`) to **re-encoded** audio on every segment-assembly path (software, hwaccel, and audio-only). **Copy-audio rungs** (e.g. the `original` variant, `-c:a copy`) and **direct-play sessions bypass loudness normalization by design** — you cannot filter a stream that is copied rather than decoded (documented in the config block). - The `X-Phlix-Client-Capabilities` request header (a JSON codec-support map, e.g. `{"eac3":false}`) is now honored on the `/playback-info` `direct_play` verdict: a client that declares it cannot decode the item's audio codec is steered to transcode instead of direct play. Absent/empty/invalid header → `direct_play` stays `true` (backward compatible). The verdict keys on the same first (lowest-`stream_index`) audio stream the transcode copy-vs-encode decision uses, so the two agree. - **SV-1.9 — configurable segment-cache disk-space threshold.** New `hls.min_disk_space_bytes` config key (env `HLS_MIN_DISK_SPACE_BYTES`, default **500 MiB**). When free space on the segment-cache directory falls below the threshold, the server sweeps the cache and returns **`503`** with `Retry-After: 3` instead of failing an encode with `ENOSPC`. - **colors.css ⇄ the server transcription are now diffed mechanically, and the legacy-alias justification is a measured census (S228).** `@phlix/tokens/src/css/colors.css` — the canonical stylesheet behind `BuiltInThemes::PAYLOADS` and `ThemeTokenAllowlist` — lived in another repo that nothing in CI could read, so both were pinned against hand transcriptions (the test file restated 159 values; the allowlist test restated 53 names) and a hex edit upstream was invisible estate-wide. The fix: a dev-only composer package `detain/phlix-tokens`, fetched by the `composer install` CI already runs, delivered from the immutable commit SHA `5500d70b…` (tag v0.2.0; its `colors.css` is byte-identical to master tip today), plus a guard that pins the delivered bytes by sha256, parses the stylesheet, resolves its `var()` chains, and diffs name set + order + every value against the allowlist and the transcription. Both hand transcriptions are deleted — the expectation now comes from the delivered artifact. Mutating one hex on either side reddens a named test (both directions mutation-proven in this run: transcription-side edit → 1 red; vendor-stylesheet hex edit → 2 reds incl. the byte pin; one-block rename → 3 reds). The 13 legacy `--color-*` aliases S228 measured dead stay, and `LEGACY_ALIAS`'s docblock now states why with a 2026-09-05 estate-wide reader census (12 of 13 have zero `var()` consumers across server `src/`+`web-ui/`, ui, windows, tizen, mobile, roku, console and hub at their origin tips; `--color-primary`'s only reader is the ui's dev-only swatch page; phlix-website's same-named vars are its own per-site palettes, a disjoint namespace): removal is a coordinated cross-repo deprecation — it would fight the new name-parity guard, make `ThemeSourceRegistry::register()` reject the published sample plugin (which sets all 13 today), byte-change the `GET /api/v1/themes` body that is md5-pinned against a copy in phlix-ui, and desync phlix-ui's mirrored `THEME_TOKEN_ALLOWLIST`. The S84 "un-migrated components" folklore is gone. Collateral, disclosed: `scripts/release.sh`'s root-`version`-field detector matched `"version"` at ANY indentation, so the legitimate nested one inside the new inline composer `repositories` entry false-reddened 11 `ReleaseScriptTest` cases; both the script and one test now ask the decoded JSON root instead of the raw text. No migration. ### Changed - **Composer: `justinrainbow/json-schema` moved `^5.2` → `^6.0` (locked 5.3.4 → 6.14.0; new transitive `marc-mabe/php-enum`).** The v5 line was the last constraint lag in `require`; 6.x is the current major and the validator call sites (`ManifestSchema`, `AdminSettingsController`) are API-stable across the move — `validate()`'s new `int` return is unconsumed everywhere and `isValid()`/`getErrors()` are unchanged in shape (62 usage-site tests + full phpstan green on 6.14.0). The `phlix-tokens` dev-pin half of that deferred item stays at v0.2.1 (proven no-op; the v0.2.2 cascade is a standing owner-deferred item, not lane-work). (Deferred-items lane, review minor #9.) - **The served web SPA bundle re-pins to `@phlix/ui` v0.99.13 and is regenerated (IDs: tag-wave C1 follow, 2026-11-02).** - `web-ui` tarball pin moves `v0.99.12.tar.gz` → `v0.99.13.tar.gz` (tag object `ff9c1f91cf7b08a425e654a0717ec73b9a14cc41`, peel `e30bae9f096708dc43e863c2b4f4171d25d7c8e3`, both ls-remote-verified). v0.99.13 is a small follow-up release: the Select dropdown's internal open-state ref renames `open` → `isOpen` so it no longer collides with the `v-open` model prop under the refreshed eslint-plugin-vue era (public API unchanged), plus a toolchain-era dist rebuild from the owner's dep-refresh. - Lock diff surgical 4+/4-: the root dependency echo plus the `node_modules/@phlix/ui` version/resolved/integrity lines. Transitive shas deliberately held: v0.99.13 pins the same tags as v0.99.12, and the lock's `@phlix/contracts` `0.5.4@c491985317…` and `@phlix/syncplay` `0.1.6@081687742…` resolutions equal the live tag peels (ls-remote-checked). The toolchain era stays put too — a blanket `npm update` would rotate dev deps (vite 8.2.0 → 8.3.5 and friends), which the era law refuses; the lock was moved by a scoped `npm install` at the CI-pinned toolchain (node 24.20.0 per `web-ui.yml` + bundled npm 11.19.0, `NPM_CONFIG_USERCONFIG=/dev/null`). - `public/assets/app/` regenerated after a clean `npm ci`: `vue-tsc --noEmit && vite build` run twice → aggregate md5 over the tree (`find public/assets/app \( -type f -o -type l \) | sort | xargs md5sum | md5sum`) = `2d71b86991798d24aeb3adfdd8c8f3d1` on BOTH builds (deterministic). Prior committed tree measured `e1b0ac6f8462aaee86ddcb29d0277bb0` with the same recipe. Tree shape: 200 tracked → 200 on disk; 167 chunk renames, 2 in-place updates (`index.html`, `.vite/manifest.json`). - Proofs: installed `node_modules/@phlix/ui/dist` byte-identical (diff -r) to the v0.99.13 tag tarball fetched via curl into scratch; the served bundle opens WebSockets with the "bearer" subprotocol array (backtick-quoted literal in this toolchain era's minified output), ZERO `[?&]token=` query-string leaks, and the PlaylistsPage/PlaylistDetailPage chunks still emit. The v0.99.13 Select cure is an internal-ref rename the minifier does not preserve as a runtime string — the served-bundle law is met by the tarball→dist→build identity chain instead. Gates: web-ui `node --test` 33 pass / 0 fail (baseline exact); the bundle-aware PHP files (WebUiBundleGateTest, ViteAssetsMemoTest, SharedUiControllerCspNonceTest, NginxAssetLocationTest, PublicFrontControllerRemovalGuardTest, HttpHandlerServeStaticTest) green — 41 tests / 359 assertions. Zero era-pin rotations: no test or script in this repo cites the pinned ui version (pin-skew grep measured empty). - Disclosed, NOT chased (era law): `npm audit` in web-ui reports one dev-scope high — the vite 8.2.0–8.2.2 GHSA family (launch-editor escaping + Vite dev-server classes). Pre-existing at the v0.99.12 lock (which pinned the same vite 8.2.0), build-time-only (vite is a devDependency, never in the served bundle), and no web-ui CI step gates on audit. - **Wave-2 tag-wave re-pin: `detain/phlix-shared` and the `@phlix/ui` pins move to the released tags and the served `/app` bundle is regenerated (IDs: tag-wave C1, 2026-11-02).** - `composer.json` gains `detain/phlix-shared: ^0.53.0` (lock re-resolves v0.51.0 → v0.53.0 only; source ref `9e784c60`). The 84-key server settings schema is key-set-identical to v0.51.0, so no server-side admission allow-list moves; the two substantive (non-prose) schema deltas are `subtitles.default_language` default `"eng"` → `"en"` — aligning the schema with the ISO 639-1 consumer the router already has — and `trakt.redirect_uri` gaining `format: "uri"` + `pattern: "^https://"`, the law PUT validation already enforced through the full property subschema and already honored by every existing test value. `detain/phlix-tokens` stays exact `v0.2.1` (owner defer). - The `web-ui/package.json` tarball pin moves `@phlix/ui#v0.99.10` → `#v0.99.12` (tag object `93b6d186`, peel `1f0046c5`); the lock rotates the transitive pins to `@phlix/contracts#v0.5.4` + `@phlix/syncplay#v0.1.6`, the installed `dist/` is byte-identical to the tag tarball, and the web-ui gate suite passes 33/33. The served `public/assets/app` SPA is rebuilt with npm 11.19.0 / node 24.20.0 (two-build aggregate md5 `eb31af09bffa88d5aa81496ec56fdbad`, identical), so the member `/app/playlists` pages and the playlist-manageability gating are now live in the hosted bundle — the README "UI truth at this tip" line flips accordingly (supersedes the v0.99.10-era statements forward-only). - **Hygiene sweep makes `src/` 100% strict-typed, deletes fifteen verified-dead classes, and retires two more dead subsystems (IDs: SRV-HYGIENE micro-lane, 2026-10-14 review family — R18-03/07/09/10/16/17, R15-23/29, and three dead-code retirements):** - R18-03: `declare(strict_types=1)` added to the last seven files missing it (`AuditLogger` + the six `src/Dlna/` classes); `src/` is now 100% strict. No coercion fallout — Dlna/Common/Integration filters all green. - R18-07: fifteen verified-dead classes deleted (RemoteAirPlayClient, DebugLogger, RecordingHooksRunner + RecordingHooks, HlsRelaySessionFactory, Dvbt/HdHomeRun tuner driver factories, Audiobook/Book/Photo LibraryType, the two Hwaccel exceptions, TranscodeProfile, SessionOidcStateStore, Webhooks PluginRegistry). `MusicLibraryManager` was KEPT — it gained a live `new` at `MusicLibraryType` (plugin LibraryType contract) after the review. Census `EXPECTED_PHP_FILES` re-pinned from the measured red. - R18-09: `SmartRuleVocabulary` moved `Phlix\Collection\` → `Phlix\Collections\` (PSR-4 dir/namespace agreement); sole consumer import updated; twin dir gone. - R18-10: `src/Server/WebSocket/Events.php` no-op compat shim deleted (its phpstan keep-reason was retired at S343; `excludePaths` stays `[]`). - R18-16: loose compares tightened — RatingGate admin flag now uses the estate `!empty()` idiom (with a 5-case pin), PhotoScanner EXIF rationals parse both halves then compare strictly (string '0' denominators no longer produce INF), and MediaScanner's JSON-round-trip array `!=` is annotated as intentionally order-insensitive rather than blindly tightened. - R18-17: `RelayConfig::fromEnv()`'s ten `getenv(...) ?: default` sites replaced by typed `envString`/`envInt`/`envFloat` helpers — unset and empty-string are both explicitly "not provided", '0' keeps its old falsy fallback, garbage strings keep the old cast semantics; 5 new pin tests. - TimeSync vestiges retired: the standalone :8098 worker died in 22ce232c; `DEFAULT_PORT`, the `$host`/`$port` ctor params and `getHost()`/`getPort()` (zero production readers) removed with a tombstone docblock. - Helm phlix chart: `PHLIX_HUB_ENROLLMENT_JWT` env + `hub-enrollment-jwt` secret key + `hub.enrollmentJwt`/`hub_pairing_code` values dropped — zero PHP readers; enrollment is file-based (`hub-enrollment.json`). - R15-23: success-message taxonomy — one canonical string per operation ('Settings updated.', 'PIN cleared successfully', 'Profile deleted successfully'); one admin test pin re-pinned with attribution. R15-29: newsletter tpl subject defaults aligned to the generator's 'Your Phlix Weekly Watch Report'. - Foreign-gate repair: `WebPortalRouterParentalEnforcementTest` updated to RecommendationService's post-R19-12 single-arg ctor — `phpstan -c phpstan-tests.neon` fully green again. - **SRV-OPS remediation (2026-10-14 review family) — R14-05 README env census + R14-20 restart semantics:** the README's "Environment variables" section claimed six knobs; the runtime actually reads ~100. Replaced it with the definitive grep-censused inventory (every `getenv()`/`$_ENV` read in `src/`, `config/`, `start.php`, `bin/`, `scripts/`), grouped by subsystem, each row carrying Default / Secret? / Restart class / consuming file. Honest truth markings included: `PHLIX_MIGRATIONS_STRICT` and `PHLIX_SECRET_KEY` are docker-entrypoint shell-side names, `PHLIX_HUB_ENROLLMENT_TOKEN` / `PHLIX_PUBLIC_URL` / `PHLIX_HWACCEL` have zero read sites in this repo, and the `config/playback.php`/`config/port-forward.php` knobs are `$_ENV`-only (inert under the common `variables_order = "GPCS"`). New "What requires a restart" subsection classifies every knob as boot-read, per-process-memoized, or live. Config example port corrected 8080 → 8096 (truth again post-R14-06). - **The `@phlix/ui` tarball pin moves `v0.99.8` → `v0.99.9` and the served `/app` bundle is regenerated — the grid 'Add to playlist' flow now honors the server create contract in the shipped SPA.** The v0.99.9 tag (commit `cc931723`, tag-form pin per the ui-pin-skew gate) carries the playlist fix `09bf8248` (dist `7792546c`): `createPlaylist(name, libraryId)` sends exactly `{name, library_id}` to `POST /api/v1/playlists` — matching `CollectionController::create`'s required key (the flow 400ed for every role before) — parses the `{collection}` 201 envelope with fail-loud `ApiError`s on malformed 200s, and chains `POST /api/v1/collections/{id}/items/{mediaItemId}` so the item actually lands. This closes with the server's own `library_id` shape() addition (`c42e166a`, the Unreleased Added entry above): fix and column arrive together on the same render path. Riding along: the contracts `#v0.5.3` re-pin (204-code error catalog — `syncplay.queue_limit_exceeded` now present in the served corpus; dist `db114905`) and the `library_id` wire-truth doc pass (dist `06b1253b`). Vendored identity proven at install: `node_modules/@phlix/ui/dist/phlix-ui.js` is sha256 `5efa7cb5de88ee6941095361164ebc8e602ce8590fa7db51edd912858aa78cdc` — byte-identical to the tag's tree blob at v0.99.9. Lock regen is minimal (npm 11.19.0 / node 24.20.0, `npm_config_userconfig=/dev/null`): 7+/7− — the root echo, the `@phlix/ui` node (honest `0.99.9`, new resolved+integrity, contracts dep `#v0.5.2`→`#v0.5.3`), and the `@phlix/contracts` git resolution rotating to `430981e3…` (npm kept the stale lock entry on the first pass; the node was dropped and re-resolved from ui's declaration). `@phlix/syncplay #v0.1.5` untouched; `npm ci` clean. `public/assets/app/` rebuilt CI-faithfully (212 files, three consecutive `vite build`s byte-identical via md5 census; the S253 gate's `git diff --exit-code` + index-vs-disk set checks replay green against the committed tree). Grep proofs on the new served corpus: the `{name:t,library_id:n}` create payload lives in `client-*.js`, the per-leg failure toasts in the `MediaCard`/`MediaDetail` chunks, the `['bearer', …]` WS carrier is still live, zero `[?&]token=` hits (incl. source maps). `web-ui` `node --test` is 33/33 against the new pin, including the registry-parity drift guard. Zero PHP is touched. - **Collections ownership (Option A): per-user collections replace the interim admin gate — members regain self-service CRUD, foreign rows answer the not-found shape, active admins stay omniscient (owner decision #6 landed, superseding the c53b1490 interim posture from 2026-10-01).** `migrations/112_collections_created_by.sql` adds `collections.created_by CHAR(36) NULL` + `KEY idx_col_owner` (both guarded in the migration-107 INFORMATION_SCHEMA/PREPARE idiom) and backfills legacy rows in the same file with pure DML in the migration-109 election shape: the earliest ACTIVE admin (`is_admin = 1 AND status = 'active'`, `ORDER BY created_at ASC, id ASC`) is the anchor; with no active admin the earliest-created user of ANY status takes it; an empty `users` table leaves rows NULL. NULL-row policy (documented + enforced in code): legacy/unowned rows stay visible to every authenticated user but writable only by active admins — the degenerate-case mirror of the interim gate. Authz mirrors the syncplay room-visibility precedent (1ef503b7) exactly where possible: `CollectionController` gains a trailing-optional `?UserRepository $adminUsers = null` predicate source (unwired = fails closed to non-admin) and every handler fails closed to the byte-identical AuthMiddleware 401 envelope (`{error:Unauthorized, code:auth.required}`) when `$request->userId` is absent, even though the routes are already `[AuthMiddleware]`-gated. LIST reads are SQL-scoped: members get the new `CollectionRepository::findAllVisibleTo()` / `findVisibleByLibraryId()` (`created_by = ? OR created_by IS NULL`), active admins get the raw `findAll()` / `findByLibraryId()` sets. SINGLE reads and writes resolve the unscoped row first (`findById` / `getCollectionWithItems` stay deliberately unscoped — pinned owner-clause ABSENT so identity-free consumers keep working) and then run the in-handler predicate; the owner-hit fast path never consults the users table at all (laziness pinned). Every ownership miss — including legacy NULL rows faced by members — replays the existing 404 `{error:"Collection not found"}` byte-for-byte: an ownership miss is indistinguishable from absence, no 403 oracle. `create` (both `/api/v1/collections` and the `/playlists` alias) stamps `created_by = actor`; `update` carries the anchor forward immutably. The response envelope gains an additive `created_by` key on the Collection VO (openapi response schemas are generic additionalProperties — no schema edit needed). `Application::loadCollectionRoutes` folds all eleven registrations (8 writes + the create alias + 3 reads) back into the lone member `[AuthMiddleware]` group — the documented one-edit revert path of c53b1490, now superseded by real authz in-handler. `findBySmartPlaylistId` stays OWNER-BLIND by law: the scan-time `SmartPlaylistRefreshSubscriber → SmartPlaylistRefreshHandler` path is identity-free and re-scoping it would silently rot every user's smart collections (SQL-shape pinned). Hub proxy needs ZERO hub changes: the relay path already resolves `$request->userId` from `X-Phlix-Relay-User` server-side (`src/Hub/RelayConsumer.php`), so a member create via hub lands as a correct member-owned create. Cascades: `ApplicationRouterWirePathGuardTest` 8 rows flip `[AdminMiddleware]`→`[AuthMiddleware]` (total 353 routes unchanged); `openapi.yaml` the same 8 `x-phlix-middleware` labels; the census re-pins 1944→1945 files / 1033→1030 writes (measured from red); `CollectionsAdminGateTest` is rewritten as `CollectionsOwnerGateTest` (member-A writes, member-B refused-with-404, admin omniscience, NULL-legacy policy both directions, anonymous fail-closed, stamping, carry-forward, lazy-predicate, structural route-fold proof) plus a real-MySQL `CollectionsOwnershipMigration112RealDbTest` (election arms incl. inactive-admin, backfill, replay-idempotence) and repository SQL-shape pins incl. the owner-blind anti-rot proof. HONEST TRADEOFF (owner-ratified): legacy rows become the earliest-admin's OWNED collections, so members LOSE visibility of pre-existing global lists they could see before the gate era — the non-destructive alternative to deleting shared rows — and GAIN self-service CRUD on their own collections, closing the interim gate's documented member-403 regressions (ui MediaCard playlist flows, mobile CollectionManager CRUD, console deletePlaylist, roku item ops, hub-relay creates). - **MED-2 (SyncPlay audit): SyncPlay room visibility is members-or-admin — the estate's rosters are no longer public to every authenticated user (owner ruling).** Before this change the WS `group_list` reply and the REST reads `GET /api/v1/syncplay/groups` + `GET /api/v1/syncplay/groups/{id}` served EVERY room to ANY authenticated caller — including password-protected rooms' names, member counts, what's-watching and, via the single-room state, the full member roster (user-ids + display names). Now: `SyncPlayManager::handleGroupList` answers from a new `visibleGroupSummaries()` (member of the room, or ACTIVE admin via the S1-hardened `UserRepository::findAdminById` — never a soft flag; the admin lookup runs lazily once per request, only when a foreign room is present); `SyncPlaySnapshotService::listGroupMemberships()` exposes group-id → member-ids via a server-side `JSON_EXTRACT(serialized_state,'$.members')` over the real JSON column; and `SyncPlayController::listGroups` filters the same predicate on `$request->userId` while `getGroup` refuses non-members with the EXISTING missing-room 404 envelope, byte-identical, so the endpoint never becomes a group-id oracle (deliberate 404-not-403: a 403 would confirm the id exists). Both admin predicates arrive as trailing-optional ctor params (estate DI law — 20+ existing constructions stay legal) and FAIL CLOSED: unwired = members-only, never public. Wired where authority lives: `start.php` §4a (the :8097 WS worker), `SessionServicesProvider` (`constructorParameter('adminUsers', ...)` — PHP-DI skips optional params), `Application::getSyncPlayController` (HTTP container path). JOINING IS UNTOUCHED: a non-member who knows a room id (+password) still joins over both transports — this gates READING/discovery, not joining — and creation, leaving and host-transfer rails are unchanged. DOCUMENTED CONSEQUENCE (the ruling's product effect): non-members lose room DISCOVERY — the browse list is now own-rooms-only for regular users (the phlix-ui watch-party modal's client-side public filter receives an empty set for outsiders); admins and members see exactly their own slice. Wire shape is unchanged (same six summary keys, same frames), so old clients degrade to a shorter list, never a parse break. SPEC.md §syncplay_group_list note lands in phlix-syncplay as a separate docs commit. Tests: new `SyncPlayGroupListVisibilityTest` (WS, 6) + `SyncPlayVisibilityRestTest` (REST, 8) + `SyncPlaySnapshotMembershipsRealDbTest` (real MySQL JSON read, 3; guard census 64→65); the S415 envelope pin venue enumerates the new admin SELECT with `pin_user` pinned as ACTIVE admin so the byte-pinned envelopes and the contracts digest cross-check are untouched; the write-through bridge probe is likewise admin-wired (its subject is convergence, not visibility). - **M-5 interim gate: collection MUTATIONS are admin-only while the collections ownership model stays unresolved (open owner decision #6); the three reads stay member-accessible.** `migrations/005_collections.sql` has no owner column, so every collection row is server-global — and `CollectionController` carries zero internal authz, so before this change ANY authenticated user (kid profiles included) could create, rename, delete and bulk-mutate the shared collection set through the ten `AuthMiddleware`-only rails of `Application::loadCollectionRoutes()`. The block now splits into a read group (`GET /api/v1/collections`, `GET /api/v1/collections/{id}`, `GET /api/v1/libraries/{libraryId}/collections` — unchanged `[AuthMiddleware]`) and a write group (`POST /collections`, `PUT|DELETE /collections/{id}`, `POST /playlists` create-alias, `POST|DELETE /collections/{id}/items/{mediaItemId}`, `POST …/bulk-add`, `POST …/refresh` — a lone `[AdminMiddleware]` resolved from the container, the same idiom as `DELETE /api/v1/media/{id}` (Step 11.6): the middleware itself answers anonymous → 401 `auth.required` and authenticated NON-ADMIN → 403 `auth.not_admin` plus a `logPermissionDenied` audit row, and the try/catch keeps the write group structurally unregistered (404) if the gate cannot resolve — never registered-ungated. Cascade: the eight wire-path-guard manifest rows label-flip `[AuthMiddleware]`→`[AdminMiddleware]` (tuples unchanged — proved by regenerating the @phlix/contracts route manifest from the HEAD and worktree ROUTE_MANIFEST sources: byte-identical 412 tuples, md5 `636c420ecb0375ff895fac437230c6f3`, so NO contracts re-vendor and no consumer pin moves; `composed-route-inventory.json` keys are plain `"VERB /path"` strings and also ride untouched), and the same eight `openapi.yaml` ops re-annotate their `x-phlix-middleware` (all already documented a 403 response). Census re-pins measured from the phpunit red: `EXPECTED_PHP_FILES` 1926→1927 (new `CollectionsAdminGateTest`), `EXPECTED_DECLARED_WRITES` 1019→1024 (its five `$request->` fixture stamps); declared reads stayed 422 (verified from the same run). **Accepted interim per owner ruling: every non-admin write now 403s.** Known consumer impact (reported, not client-fixed here): phlix-ui's member-facing "Add to playlist" (`MediaCard.vue` → `createPlaylist` POST /playlists + `addToPlaylist` POST /collections/{id}/items/{mediaId}) and phlix-mobile's `CollectionManager` full CRUD now 403 for non-admin users; phlix-console's `deletePlaylist` likewise; phlix-roku's item add/remove helpers likewise (its F8 collections browse is GET-only and unaffected); the hub's relay scope map deliberately allowlists exactly ONE collection write — anchored `POST /api/v1/playlists` (HB-3.1) — and proxies the MEMBER's own identity via `X-Phlix-Relay-User`, so non-admin relay playlist-creation is the precise member-UX regression path over the hub; every other collection write already fails closed hub-side (`proxy.scope_denied`) before reaching the server. **REVERT PATH: when the ownership model lands, fold the write group back into the member group — one group edit in `loadCollectionRoutes()` — and gate per-owner inside `CollectionController`; the flip is pinned both ways by `tests/Unit/Server/Core/CollectionsAdminGateTest.php`.** - **Wave-3C cascade: the vendored @phlix/contracts registry re-vendors `v0.5.1` → `v0.5.3` and the SyncPlay queue-overflow refusal stops stretching `syncplay.group_limit_reached` — it now emits its own registered code `syncplay.queue_limit_exceeded`.** The fixture is the byte-copy of `dist/error-codes.json` from the `v0.5.3` tag tarball (codeload `refs/tags/v0.5.3`; content md5 `40c1da48787f37d00a6fc9af6c64a1ac`): 202 → 204 codes, additive (old list verified an order-preserving subsequence of the new; added `leaf_hub_id_already_bound` + `syncplay.queue_limit_exceeded`; zero removals). `CONTRACTS_TAG`, the sidecar pin, and the emit-law provenance docblock move in lockstep; `MIN_REGISTRY_CODES` stays a 147 FLOOR (not a pin), so no count-rotation was measured. The emit switch is a same-line string swap in `SyncPlayManager`'s PLAYBACK_QUEUE handler (`count($parsedQueue) > GroupState::MAX_QUEUE_SIZE` arm) with a net-zero line delta ON PURPOSE: @phlix/contracts `errors.ts` cite coordinates into this file (via `scripts/check-error-cites.mjs`) verified 431/0 immediately after; the group-count cap in `createGroup()` (`count($this->groups) >= MAX_GROUPS`) KEEPS `syncplay.group_limit_reached` — that is the documented split. The server's `openapi.yaml` `Error.code` enum deliberately does NOT gain the two new members: the exact-equality law (`ErrorCodesOpenApiEnumContractTest`) pins it to the REST-emittable scan, and both codes are non-REST-channel (`queue_limit_exceeded` rides the WS `error_code` envelope; `leaf_hub_id_already_bound` is hub-federation vocabulary) — the enum comment restamps to v0.5.3 order accordingly. Wire-compatible for pass-through clients: `Messages::error()` forwards the code verbatim; ui `errorCodeMessage` degrades locale-catalog → en → server-text fallback without throwing, and mobile `describeSyncPlayError` falls back to the server message on unmapped codes — no client drops the new value (their registries re-pin in their own 3C lanes; mobile's catalog-pinning test will demand the one added row at its v0.5.3 re-pin). - **The `@phlix/ui` tarball pin moves `v0.99.7` → `v0.99.8` and the served `/app` bundle is regenerated — the SPA's SyncPlay handshake flips from the `?token=` query carrier to the `Sec-WebSocket-Protocol: bearer, <jwt>` carrier, retiring the last `?token=` producer in the estate-served corpus.** The v0.99.8 tag (commit `a580410f`, tag-form pin per the ui-pin-skew gate) carries four real deltas into this repo's build inputs, unlike the v0.99.7 zero-delta re-pin: (1) the syncplay WS dialer sends `['bearer', token]` and no longer appends `?token=` to the `:8097` URL — the browser half of the dual-carrier law `SyncPlayAuthMiddleware` already accepts, closing the retirement ledger's `phlix-ui` row in `docs/dev/WEBSOCKET_AUTH_CARRIERS.md`; (2) `LibraryScanPage`/`LibrariesPage` now guard every `.paths` read with `paths?.` (the second L-4 layer 114c9aaf called out as riding "the next owner-gated tag cascade" — it has now landed in the served bundle, not just behind the route gate); (3) the invite-links DTO moves to the create-token/`/redeem` shape; (4) the `ui.css`→`player.css` export rename (this app imports only root/`style.css`/`fonts.css`, so it rides along unused). Vendored-blob identity was proven at install: `node_modules/@phlix/ui/dist/ phlix-ui.js` is sha256 `63fe4ab3696d3e46e89cedae9147fccb07da86df91e7047ba03b220c92c8ef63` — the same blob windows and tizen pinned at this tag — and the lock's tarball `sha512` integrity matches an independently fetched tag archive. Lock regen is minimal (npm 11.19.0 / node 24.20.0, `npm_config_userconfig=/dev/null`): the root echo, the `@phlix/ui` node (honest `version: 0.99.8`, new resolved+integrity, `pinia`/`vue`/`vue-router` moved out of its `dependencies` as they became peer-only upstream), and npm's `"peer": true` markers on the auto-installed peer subtree — zero version/resolved/integrity moves anywhere else, and the nested `@phlix/contracts #v0.5.2` / `@phlix/syncplay #v0.1.5` git resolutions are untouched (the tarball still declares both). `public/assets/app/` was rebuilt CI-faithfully: 212 files, `npm ci` clean, two consecutive `vite build`s byte-identical, the S253 gate's `git diff --exit-code` + index-vs-disk set check green against the committed tree. Grep proofs on the new served corpus: zero `[?&]token=` hits (incl. source maps; the old `SyncPlayModal-*.js` carrier is gone), `bearer` subprotocol live in the entry + SyncPlayModal chunks, `paths?.` guards live in the entry + LibrariesPage chunks. `web-ui` `node --test` is 33/33 against the new pin, including the registry-parity drift guard and the `buildAdminRoutes()` meta cross-check. Zero PHP is touched. - **NAT-PMP wire residuals closed against fetched RFC 6886: request destination port 5351, deletion result-code gate, granted-lifetime-aware lease and renewal bookkeeping.** Three audited residuals in `src/Network/NatPmpClient.php` (plus one consequence in `PortForwardService`) were verified line-by-line against `rfc-editor.org/rfc/rfc6886.txt`. (1) **Request port.** Every NAT-PMP request (address, mapping, deletion) was being sent to UDP **5350** — but §3.1 (line 323) says "a NAT-PMP client sends its request packet to port **5351** of its configured gateway address", §3.2 (line 378) and §3.3 (line 512) repeat it, and the §3.2.1 engineering note (lines 481-482) pins the split: "clients listen on UDP 5350 and servers listen on UDP 5351" — 5350 is where a client listens for the gateway's 224.0.0.1 announcement multicast (§3.2.1, lines 432-433), never a request target. A spec-compliant gateway therefore never saw any of our requests. The constant is now `NAT_PMP_GATEWAY_PORT = 5351` (renamed from the ambiguous `NAT_PMP_PORT`), and the fork-responder tests bind exactly where the RFC says the server listens, making every round trip a destination-port tripwire. The client-side announcement listener on 5350 (§3.2.1/§3.6 reboot detection) remains **not implemented** — documented in the class docblock; no code path in `src/` binds 5350 or 224.0.0.1. (2) **Deletion result gate.** `removePortMapping()` returned `true` on the reply's opcode echo alone, so a §3.4 "Not Authorized" refusal (result code 2, lines 718-723: an unsuccessful deletion "MUST contain a non-zero result code and the requested mapping") acknowledged as teardown success while the mapping stayed alive on the router. Deletion replies are now gated on result code 0 via a new shared `readResultCode()` helper (the same §3 bytes-2-3 extract `parseExternalIp()`/`parseMappingResponse()` use; both refactored onto it); idempotent re-deletes of gone mappings still ack true because §3.4 (lines 712-717) mandates a code-0 response for them. (3) **Granted lifetime and renewal bookkeeping (§3.3).** The client requested a fixed 3600 s lease — half the RFC's "RECOMMENDED Port Mapping Lifetime is 7200 seconds" (line 575) — and threw away the reply's granted lifetime, which §3.3 (lines 664-666) lets the gateway reduce below the request. `parseMappingResponse()` now returns `array{external_port:int, granted_lifetime:int}` (length guard raised 12→16: a reply without bytes 12-15 cannot answer the expiry question), `addPortMapping()`/`mapPort()` pass the record through, and the default lease is 7200. `PortForwardService::autoConfigure()` logs the granted lifetime and persists `mapping_granted_lifetime` plus `mapping_renew_at = now + granted/2` — §3.3 (lines 679-681): "The client SHOULD begin trying to renew the mapping halfway to expiry time, like DHCP" — and `disable()` clears both keys. **No renewal timer is armed**: renewal needs a resident timer, no worker owns port-forward state today (autoConfigure is request-scoped in `AdminHubController` and one-shot in `scripts/port-forward.php`; the resident HubClient worker only reads candidates), and a `Workerman\Timer` armed from a request dies with the request. The deadline is persisted for whichever owner decision assigns a renewal owner; the rationale is pinned in a `TODO(arch)` block on the natpmp leg rather than hacked into an event loop. Pins: destination-port constant test, 5351-bound fork round trips (add/unmap-success/ unmap-refused), granted-lifetime-differs-from-request wire assertions, half-life persistence proof, disable-clears-keys proof — each mutation-proven red on the pre-fix shapes. - **Security bump: `phpseclib/phpseclib` `3.0.55` → `3.0.57` (CVE-2026-84308 / GHSA-q97c-8qh3-fpc6).** The Coding Standards `Security Audit` job (`php scripts/security-audit-check.php`, which blocks any advisory against `composer.lock`) went red 2026-09-29: the advisory covers a non-constant-time X25519 scalar multiplication permitting private-key recovery, affected `<3.0.57|>=4.0.0,<4.0.1`. The only reverse-dependency is this repo's own `require: phpseclib/phpseclib ^3.0` (`composer why` shows no other package pulls it), and upstream `3.0.57` was already released — a plain `composer update phpseclib/phpseclib` resolves it with `composer.json` untouched and the lock diff limited to the phpseclib entry (the composer 2.10 serialization side-effects — `plugin-api-version` and `[]`-vs-`{}` empties — were reverted to match the repo's existing lock shape). Zero `phpseclib3` references exist in `src/`, `scripts/` or `tests/`, and the bump is patch-level inside the 3.0 line. Gate evidence: audit script exit 0 (`Security audit passed`), Unit suite 11364 tests / 0 failures / 0 errors, both phpstan corpora and both phpcs corpora zero-error, `composer validate --strict` clean. - **The `@phlix/ui` tarball pin moves `v0.99.6` → `v0.99.7`; the served bundle is byte-identical (cascade-currency re-pin).** The v0.99.7 tag (annotated `da9f1625663f8ddb52213f8069d2a494cd1ceee2` peeling to commit `bc1d29bf98cb0e847aca05e44733d41ef2381b10`, both verified via `ls-remote`; pinned in tag form because the estate's ui-pin-skew gate accepts tag-form pins only) is the ui release lane's cascade tag whose dist is zero-delta against v0.99.6: the tarball's only changes are its embedded `package.json` version field — the long-standing `0.99.4` lag was fixed upstream — and its transitive `@phlix/contracts` declaration moving to `#v0.5.2`. The lockfile regen is minimal and honest, with two deliberate deviations from #795's three-field shape: (1) `node_modules/@phlix/ui` now reads `version: 0.99.7` — holding the stale `0.99.4` would have misrepresented the new tarball's bytes (the convention was always to echo the tarball, not to preserve the skew); (2) the nested `@phlix/contracts` resolution had to move from the `#v0.5.1` peel to the `#v0.5.2` peel (`7afb6a9171c33c18a2303716516572a4dfc405d9`, version field `0.5.2`), because npm 11.19.0 treats existing nested git resolutions as authoritative — the documented a8c13e14 pathology — so the proven surgical recipe (drop exactly the stale nested entry, re-run `npm install` under node 24.20.0 / npm 11.19.0 / `npm_config_userconfig=/dev/null`) re-resolved it from the echoed spec, leaving the lock self-consistent. Rebuilt CI-faithful: `npm ci` passes and two consecutive `vite build`s are byte-identical across all 212 files — and the emitted tree is **byte-identical to the bundle v0.99.6 already served** (8,841,861 bytes; entry chunk `index-Dp3Wb4b7.js` unchanged at 430,416 bytes). The dist zero-delta claim held all the way through vite: `@phlix/contracts` has zero live import edges into the bundle (a8c13e14's inertness proof) and neither the version field nor the declaration string reaches any build input. The diff of this change is therefore exactly `web-ui/package.json`, `web-ui/package-lock.json` and this file — `public/assets/app/` untouched, the S253 gate green with zero bundle delta. `web-ui`'s `node --test` suite is 31/31 including the registry-parity drift guard re-checked against the new pin. Zero PHP is touched. - **SyncPlay WS twin flip: the three coarse fallback `error_code` values are now their dotted registry twins (WIRE CHANGE).** `handleGroupCreate`/`handleGroupJoin` fallbacks emit `syncplay.create_failed`/`syncplay.join_failed` (in place of legacy `CREATE_FAILED`/`JOIN_FAILED`) and the `handleGroupLeave` wrap emits `syncplay.leave_failed` (in place of legacy `LEAVE_FAILED`). The human `message` prose on all three frames is byte-identical; only the machine code value flips. The dotted twins were already reserved members of the vendored @phlix/contracts `v0.5.1` registry (contracts #84 wording: "flip pending at <site>; never emitted today" — now landed), so the positional emit law holds unchanged with no re-vendor. Clients are PREPPED for both shapes: console #167 maps SCREAMING and dotted to the same key, roku #90's census is EMITTED(16) ∪ RESERVED(3), the ui 202-catalog and mobile 19-code map already carry all dotted twins — old servers keep speaking SCREAMING and those dual mappings keep both eras readable. The SCREAMING aliases remain registered (legacy vocabulary), they are simply no longer emitted by this server; `tests/Unit/Session/SyncPlay/SyncPlayTwinFlipErrorFrameTest.php` pins the flipped leave frame on the wire, the still-winning promote arm, and (as a labeled legacy guard) the trio's absence from the emit scan. No REST surface changes; `openapi.yaml` never named the WS trio (Error.code is the REST-channel enum by design) and is untouched. - **A `.gitattributes` EOL shield lands over the byte-faithful pin surfaces, and the repo's only CR-bearing text blob is renormalized (PR #797; `6018265c`, merge `1113c67d`).** Census at adoption (master `273adfc1`): 2508 tracked blobs, CR bytes present in exactly one text file — `tests/Support/Dash/xlink.xsd`, a verbatim CRLF fetch from w3.org (README provenance: 8 322 bytes as-fetched → 8 299 after the documented line-27 `schemaLocation` edit → 8 029 after this LF normalization) — plus four binaries now explicitly guarded (`*.woff2` ×3 bundle fonts, `*.mp3` ×1 ffmpeg-probed fixture). `* text=auto eol=lf` takes precedence over Git-for-Windows' `core.autocrlf=true` default, so the strict pins stay strict instead of learning to tolerate CRLF: the 212-file committed `public/assets/app` bundle (S253 rebuild + `git diff --exit-code` gate), the tag-pinned `tests/Fixtures/Contracts/error-codes.json` byte-copy lockstep (`ErrorCodesContractTest`), the `GOLDEN_MD5`-pinned theming fixture (byte-twin in phlix-ui) and the real-ffmpeg-probed mp3. Same poisoning class fixed by phlix-windows-client PR #46; the identical shield had already landed in phlix-ui, phlix-hub and phlix-contracts. The renormalize was proven inert, not assumed: canonical C14N of the CRLF and LF renderings hashes identical — `4db55730df9a2f174c7b78dbd7dea5f09e84d8e717d5d6757cb6235e45ef41b1` both sides (XML line-ending normalization happens at parse time), `MpdSchemaTest` asserts existence/size/parseability and the relative-import graph but never file bytes, and the DASH validation suite is green on both sides (`OK (46 tests, 226 assertions)`, php 8.3.6). A whole-tree `git add --renormalize .` rehearsal staged exactly these two files. The `core.autocrlf=true` simulation (fresh worktrees of a /tmp clone) reproduced the poison on the unshielded control — CHANGELOG.md 6 509 CR lines, package-lock.json 1 878, bundle index.html 49, disk sha256 ≠ blob sha256 — and came back clean on the shielded tree: zero CR on every probe, each guarded artifact byte-identical to its blob hash, xlink.xsd checking out LF despite autocrlf. This entry closes the doc gap deliberately waived by the PR itself ("repo-tooling only, matching the hub precedent `fe2c088`") at owner request after merge; nothing about the shipped content changes. - **The web-ui lockfile's stale nested git resolutions are healed — the documented inert debt is now gone (`@phlix/contracts` `f5d8962f` → v0.5.1 peel, `@phlix/syncplay` `2fdf70bf` → v0.1.5 peel).** Since the `v0.99.6` re-pin, the lock's `@phlix/ui` entry echoed the tarball's dependency specs (`#v0.5.1` / `#v0.1.5`) while the two nested resolution entries stayed on pre-tag commits with skewed version fields (`0.3.12` / `0.1.2`) — inert, because the shipped `@phlix/ui` dist carries ZERO live `@phlix/*` import edges: the contracts registry and the syncplay client were inlined at ui build time (`//#region node_modules/@phlix/syncplay/...` provenance comments only); the remaining references are type-only `.d.ts` annotations and source-map paths. Two facts discovered while fixing it: (1) a CI-faithful `npm install` with the lock present does NOT self-heal — npm 11.19.0 treats the recorded nested git resolutions as authoritative regardless of the parent's echoed tag spec (measured: zero lock diff); (2) a lockfile-less full regen DOES re-resolve the nested pins but simultaneously carries ~40 honest registry bumps (vite 8.2.0→8.3.1 — a rolldown 1.2.3→1.2.11 bundler-engine change — vue 3.5.41→3.5.43, hls.js 1.6.17→1.7.3, …) whose bundle churn is out of scope for an inertness lane. The minimal honest regen is therefore surgical: delete exactly the two stale nested entries and let `npm install` re-resolve only them from the echoed specs (node 24.20.0, npm 11.19.0 from /tmp/npm11, `npm_config_userconfig=/dev/null`, `--allow-git=all`). Lock diff is exactly four fields — contracts `resolved` → `e3c14f07e8927224978a921e1f79406629ceb6c5` + `version` → `0.4.7` (the v0.5.1 tag tree's own package.json field; tag name is the release identity, the same documented skew ui carries), and syncplay `resolved` → `b82d4f361e9b1e3097f37ee2d1dfedf337fd4107` + `version` → `0.1.5`; no keys added or removed, the top-level `@phlix/ui` v0.99.6 tarball pin untouched. Inertness proven, not assumed: `vue-tsc --noEmit && vite build` on the fresh nested tree type-checks against the v0.5.1 registry and reproduces the committed `public/assets/app/` bundle byte-identically — twice (determinism) — across all 212 files (sha256 manifest compare), with the S253 gates run exactly as CI runs them (`git diff --exit-code -- public/assets/app/` clean, index-vs-disk set check same-file-set). The estate memory's `server nested-contracts lock debt (inert)` residual is CLOSED by this entry. - **The `@phlix/ui` tarball pin moves `v0.99.5` → `v0.99.6`; the served bundle now ships the ui error-code catalog (the client-facing half of the `SRV-UIPIN` / W2-emit chain).** The v0.99.6 tag (peels to `98a5bf389ad29701a4991986dea4cb264fb1f3ee`; pinned in tag form because the estate's ui-pin-skew grading accepts tag-form pins only) carries detain/phlix-ui's error-catalog waves: the contracts-code error catalog rendered in all 7 locales with code-first wiring through login/signup/fetchUser (#422/#425) and the player's legacy-text match fix (#424). The lockfile regen is minimal — `resolved` + `integrity`, plus the one transitive `@phlix/contracts` declaration string following v0.99.6's own `#v0.5.1` pin; the nested version fields keep their known repo-side skew (the tarball's `package.json` still reads `0.99.4`) and are deliberately untouched. Rebuilt CI-faithful (`npm ci` + `npm run build` under the CI's npm 11.19.0 — npm 12's git-dep handling poisons local dist builds — on node 24): two consecutive builds are byte-identical across all 212 files and the committed `public/assets/app/` tree passes the S253 gate locally exactly as CI runs it (`git diff --exit-code` plus the index-vs-disk set check). Bundle delta: 212 → 212 files, 8,544,311 → 8,841,861 bytes (+297,550), concentrated in the entry chunk (`index-*.js` 302,683 → 430,416) — the 202-code × 7-locale catalog riding ui's prebuilt dist is the feature; its auth/signup consumers are ui-visible string paths for every locale. `web-ui`'s `node --test` suite is 31/31 including the registry-parity drift guard re-checked against the new pin. Zero PHP is touched: the diff is exactly `web-ui/*`, `public/assets/app/**`, and this file. - **Client-reachable failure paths now carry stable registered codes on the `code` field (`W2`).** The registry caveats on `stream.limit_exceeded`, `profile.not_found` and `access.scheduled` ("currently rides the error TEXT field; Wave-2 promotes to the code channel") land: `StreamLimitMiddleware` (3 sites) and `AccessScheduleMiddleware` (3 sites) answer through `Response::error()`, and the `PreRouterFastPaths` raw-body fast paths gain the `code` key inline — while every legacy machine token in `error` (`StreamLimitExceeded`, `AccessScheduled`) stays byte-identical for text-matching clients through the rollout. The SyncPlay WS twin-split lands at the four prose sites: `createGroup`/`joinGroup` failure returns grow an additive `error_code` (`syncplay.group_limit_reached`, `syncplay.group_not_found`, `syncplay.invalid_password`, `syncplay.group_full`) and the WS wraps forward it via `$result['error_code'] ?? 'CREATE_FAILED'` (resp. `JOIN_FAILED`) — the human `message` prose is byte-identical, the coarse SCREAMING wraps stay wherever no specific twin applies, and the reserved `syncplay.create_failed`/`join_failed`/ `leave_failed` twins remain unemitted. `MessageHandler`'s handler-catch frame becomes the modern `Messages::error('HANDLER_ERROR', 'Handler error: …')` shape (text prefix preserved for the console's `str_starts_with` consumer; the TS client finally sees handler failures at all — it has no legacy-`error` case), while the JSON-parse-failure frame deliberately stays on the deprecated legacy envelope: it answers pre-protocol bytes, where the lowest-common-denominator shape is the correct reply. The four middlewares the audit named (`auth.required`, `auth.not_admin`, `dlna.forbidden`, `casting.disabled`) were verified already code-bearing — no change needed. - **Every outbound SyncPlay frame is now stamped by the `Messages` factory — the wire format the spec pins, finally the wire format we send (`S417`).** The factory's canonical envelope `{type, protocol_version: 1, timestamp: <ms>, ...payload}` existed but only `time_pong` routed through it; all other emit sites hand-merged `['timestamp' => time()]` — a SECONDS value with no `protocol_version` — on every broadcast and every direct reply (22 sites across `SyncPlayManager`, `MessageHandler` and the dormant `SyncPlayRoom`). All of them now build their frames with `Messages::frame()` (or the typed `Messages::error()` / `Messages::info()` constructors, which share the same stamping): the factory owns `type`, `protocol_version` and `timestamp` and strips any payload copies, so no site can leak a seconds timestamp or omit the version. Conformance is additive-visible: the consumer libs decode on `type` alone, so old clients keep working and gain the missing fields. The leave ack moved from the nested `{type, data}` envelope to the flat canonical shape, and the `chat_message` payload dropped its dead seconds-level `timestamp` duplicate (the envelope key is now the single authoritative value). The transport-level offender — `Connection::sendFlat()` and its interface declaration — is removed outright; zero callers remain. Payload-level `server_time` keys are untouched (they are protocol semantics, not envelope). Guard: `tests/Unit/Session/SyncPlay/OutboundFrameShapeGuardTest.php` drives every named site through the real codepath and asserts per-frame `protocol_version` + millisecond timestamp + flatness, pins the `sendFlat` removal structurally, and carries source tripwires against the exact bypass spellings — a re-planted hand-merged frame turns the site's own test red (mutation-proven). `RequestDynamicPropertyCensusExecutableTest` re-pins its estate denominator 1863→1864 for the one new PHP file. - **Helm charts and example composes now demand immutable image pins loudly (`S475`).** Both charts stopped defaulting `image.tag`: the server chart shipped mutable `latest` (every workflow push silently re-points such a deployment at different bytes) and its template fell back to `.Chart.AppVersion` — a tag never published to ghcr; the hub chart carried the same silent shape. Both now ship `image.tag: ""` with the templates wrapping it in `required`, so an empty tag aborts `helm install`/`helm template` with a message naming the deterministic immutable form each workflow publishes — `ghcr.io/detain/phlix-server:<full-sha>-<latest|intel|nvidia>`, `ghcr.io/detain/phlix-hub:<sha12>` (the hub's published `:latest` is currently frozen/stale). The five ghcr references across the three `docker/examples/` composes became required-env interpolations (`${PHLIX_SERVER_IMAGE:?…}` / `${PHLIX_HUB_IMAGE:?…}`) with the same form guidance in their failure messages. `pullPolicy: IfNotPresent` is retained — correct for tags whose bytes can never change. Chart `version`/`appVersion` deliberately not bumped; the release process owns those. Guard: `tests/Unit/Support/ImagePinDriftGuardTest.php` walks the `k8s/` + `docker/examples/` consumer surface — zero adjacent `ghcr.io/…:latest` literals (census + YAML values legs), loud-marker pins on the `required` gate and `:?` interpolations, and an anti-vacuity leg demanding every pre-fix shape be judged drift. - **Avatar and photo-route resizing now delegate to the shared image service (`S456`).** S71's plan block recorded three resize implementations in the estate — the poster pipeline (moved into `ImageResizer` by S71 and grown further by the merged S72/S73 work), the avatar square cover-fit crop, and the photo-library thumbnail cover/contain fit. This closes the remaining two: the avatar crop arithmetic moved verbatim into `ImageResizer::renderSquareCoverJpeg()` and the photo-thumbnail arithmetic moved verbatim into `ImageResizer::renderFitJpeg()`. `AvatarStorage` and `PhotoController` keep their own public APIs, validation rules (including the avatar path's stricter upload-size cap and its distinct failure messages), storage layout and HTTP response shapes — only the pixel work moved, so stored avatars and served photo thumbnails are byte-identical to before. A new census guard pins `ImageResizer` as the estate's only file containing a GD pixel-resample call, and two golden-byte tests re-run each moved algorithm inline and demand the live paths match, reddening by name if a future change forks the arithmetic again. - **The `@phlix/ui` tarball pin `v0.99.1` → `v0.99.2`, served bundle regenerated under the S253 gate (`SRV-UIPIN`).** The v0.99.2 pin-alignment wave made the hub's UI Pin Skew gate measure live skew, and this consumer was one of the four blocking rows. `web-ui/package.json` re-pins the `@phlix/ui` dependency to the `v0.99.2` tag's tarball; `npm install` under the gate's exact Node 24.20.0 refreshed `web-ui/package-lock.json` (the `resolved` URL in its root echo and in the `node_modules/@phlix/ui` entry, and that entry's sha512 `integrity` all moved), and a fresh `npm ci && npm run build` regenerated the committed `public/assets/app/` bundle so the `Web UI Build` byte-diff gate sees this source and this bundle as one truth. The lock entry's `version` field still reads `0.99.1` — that string is the stale field inside the tagged package itself: the tag is the authority, the `resolved` URL and integrity are the proof. - **S130 size-bounded log policy: app.log runs at `info`, and every file log has a real on-disk ceiling.** `config/logger.php`'s `file` (app.log) handler shipped at `level: debug` — never a considered production choice, just the inherited default, and the volume lever behind the music scan S96 had to attack at the caller. It is now deliberately `info`: the scanners' INFO summaries and every WARNING/ERROR/critical record are STILL written (the video scanner's precedent observability and the music scanner's error reporting are preserved verbatim — the config header states exactly what an operator still sees during a six-hour scan); only the verbose per-item debug chatter is dropped, and it is a one-line per-handler change to bring back. The second half of the AC — "a bounded on-disk size is enforced" — could not be met by the old `rotating_file` handler: Monolog's `RotatingFileHandler` rotates by DAY and prunes by COUNT (`max_files`), so the file *count* was bounded but no individual file ever was (the plan's own Problem text refutes `max_files` as a size ceiling). Since Monolog 3 ships no size handler, this adds a compact `Phlix\Common\Logger\SizeRotatingFileHandler` (`StreamHandler` subclass) that rolls to a numbered shard on a byte ceiling with a bounded shard chain, keeping the active file's name stable (so the admin log viewer's `*.log` glob and `^[A-Za-z0-9._-]+\.log$` tail allowlist are unaffected — rolled `.N` shards are invisible to it, no UI blast radius) and using `useLocking` + error-suppressed renames for the same concurrent-worker tolerance Monolog applies to its own `unlink()` cleanup. All four file handlers convert to it with explicit ceilings — app.log (14+1)×16 MB=240 MB, error.log 88 MB, events.log 32 MB, plugins.log 32 MB, an estate worst case of ≈392 MB hard-bounded versus unbounded before. Pinned by `SizeRotatingFileHandlerTest` (rolls, bounded shard count, hard total-bytes bound, level gate, fail-loud on a non-positive ceiling) and `LoggerConfigPolicyTest` (real config read; level is `info` not `debug`; every handler is `size_rotating` with a positive ceiling; app.log footprint pinned to 240 MB); each guard mutation-proven RED (drop the ceiling check → the 240 MB test fails on a single 34 KB unbounded file; flip level back to `debug` → RED; revert a handler off `size_rotating` → RED). Behaviour change is scoped to logging destination policy — no code that *emits* logs changed (out of scope, owned by S96). - **Auto-collection TMDB sync is enqueued, never inline in the scan loop (S215 — the S33 promise, finally true).** `MediaScanner` held the HTTP-capable `CollectionService` and called `syncCollectionForMovie()` per file inside `processFile()` — blocking cURL on this transport (`MetadataHttpClient::requestCurl` via `EventLoopTls::requiresBlockingCurl`), stalling the scan for every TMDB round-trip. The scanner now takes a `CollectionJobStore` in that constructor slot and enqueues a `CollectionJob` per newly indexed, already-tmdb-matched item (per-library auto-collections gate kept: disabled library enqueues NOTHING); `CollectionWorker` performs the sync after the scan. The inline path is gone structurally — the scanner holds no HTTP-capable collection dependency (reflection-pinned). `syncCollectionForMovie()` also collapsed its double `/collection/{id}` fetch to a single response feeding both the local upsert and the part order. Pins: `tests/Unit/Media/Library/MediaScannerAutoCollectionsTest.php` (rewritten), `tests/Unit/Collections/CollectionServiceTest.php`, `tests/Unit/Media/CollectionWorkerTest.php` (S431 denominator re-pinned 1775→1783). - **`markWatched` now drives the playback finalize path (S438 — the recorded branch of S30's either/or).** The detail-page "Mark watched" wrote only `user_item_data.watched`, while the Continue Watching rail reads `playback_state` exclusively (the estate rule), so the item stayed on the rail. `MediaUserDataController::markWatched()` now converges every `playback_state` row the user holds for the item to `stopped`/position 0 via `PlaybackController::finalizeWatchedForUser()` — the same finalize mechanism the session-side finish signal ships; no rail JOIN on `user_item_data`, no schema change. Pinned by three real-MySQL tests (`tests/Integration/Session/PlaybackFinishIntegrationTest.php`): the verdict's two finish tests plus the ruling pin; each reddens by name under planted drift. - **`getCollectionMembers()` stops selecting non-existent `media_items` artwork columns (S436).** The projection carried `m.poster_url`/`m.backdrop_url` — columns that do not exist on `media_items` (they live on `media_collections`, added by migration 064) — so every call raised MySQL 1054. Artwork is now sourced from the row's `metadata_json`, the same source `MediaItemShaper` uses, through the existing S104 `SignedUrl` re-mint and `BackdropSrcset` row-width path. Executed against real MySQL — 1054 before, green after (PR #718; the S431 executable census re-pins 1768→1769 in the same landing). No migration. - **web-ui dependency re-pin: `@phlix/ui` v0.99.0 → v0.99.1.** The `web-ui/` package tarball URL and its lockfile entry move to the v0.99.1 tag; the lockfile's nested `@phlix/contracts`/`@phlix/syncplay` declaration strings follow v0.99.1's own pins. Zero server-side change: no PHP, no route files touched — the generated route manifest is byte-identical across this delta, so every downstream currency regeneration remains provenance-only. `npm ci` in `web-ui/` passes (mirrors the Web UI Build CI job). - **Parental-controls creates now accept the shipped mobile/roku camelCase spellings additively (S234).** `ProfileTagController::createForProfile` accepts a third tag-type spelling — `tagType` — alongside the S233 pair (`tag_type` from the admin SPA, `type` from the console client); `AccessScheduleController::createForProfile` accepts `startTime`/`endTime`/`daysOfWeek`/`isActive` alongside `start_time`/`end_time`/`days_of_week`/`is_active`. Both handlers were 400-ing every mobile/roku parental-controls create because those clients ship camelCase bodies. The snake_case spelling wins when both are present (an explicitly invalid snake_case value is rejected, never shadowed — the S233 precedence property). The canonical declared shape is snake_case (@phlix/contracts v0.4.4); the camelCase acceptance exists so shipped client builds keep working against new servers. Every camelCase spelling is pinned separately in `ParentalControlsCreateContractTest` (S234 section) — dropping that spelling alone reddens (mutation-verified per key, incl. `isActive: false` not defaulting to true). No migration; no authz change; response shapes unchanged. - **Relayed direct play works end-to-end for the first time (S301 — finish S247).** Three measured items landed together. (1) The direct-play byte stream (`GET /media/{id}/stream`) moved into the transport-neutral pre-router stage (`PreRouterFastPaths::serveMediaStream()`, `couldHandle()` widened to HEAD): a relayed direct-play request previously 404'd because 0 of 353 composed routes matched the stream path, and the relay dispatcher's `/api/`-only second chance could not rescue it. Range/206/416, the per-profile concurrent stream limit and the parental `RatingGate` all moved verbatim with it, and the `profile_not_found` branch — which had ZERO test coverage and would have 403'd every relayed stream while only the hub UUID crossed the tunnel — is now the honest named refusal, pinned by two new tests. (2) The relayed 206 now carries its `Content-Range` (previously only Workerman's encoder synthesized it, which the tunnel never invokes; the hub only PRESERVES the field). (3) An authenticated hub→server identity mapping: the hub's `X-Phlix-Relay-User` principal (stamped after the hub authenticated the relay user and verified server ownership, carried over the enrollment-JWT-authenticated tunnel) is resolved ENTIRELY from the server's own `user_identities` rows (provider `hub`), written only by the new `POST /auth/identities/link/hub` endpoint, which proves the hub identity by cryptographically verifying the hub JWT (Ed25519 + iss/aud/server_id/exp — the same validator as the legacy hub-token exchange). The parental rating gate therefore finally fires for relayed playback; an unmapped principal keeps its hub UUID (auth presence only) and an unknown `x-phlix-relay-*` marker refuses the mapping entirely with a warning. S247's pinned test is re-derived: a profile identity never crosses the tunnel (the whole `x-phlix-relay` family is stripped from forwarding server-side too). Live hub→server round-trip proof: `206` with a correct `Content-Range` and bytes md5-identical to disk; `HEAD` with no body and a single real `Content-Length`; the rating gate firing (404 `Media not found` for an over-cap item) with a succeeding 206 control beside the refusal. Two production defects were found by that live proof and fixed: the relay tunnel's outbound WebSocket used the SERVER-side protocol class (`Workerman\Protocols\Websocket`), which has no client handshake, so the tunnel could never connect (now `Ws`, the client protocol, pinned by test); and the container-built `PreRouterFastPaths` resolved with a null container because PHP-DI skips optional ctor params (now bound explicitly). No migration (next-free server migration stays 103); no version bump. S81 (profiles self-service) is unblocked by the identity mapping per the coordinator's identity-direction decision (2026-08-27). - **Every coroutine-fork site in `src/` is now inventoried and its arms exercised (S196).** The fork inventory is a machine-checked record in `tests/Unit/Support/Coroutine/ForkInventoryGuardTest.php`: a TOKENIZED scan (`php_strip_whitespace`, temp-file form — docblocks can never count) over `src/` for the two guarded idioms (`WorkerContext::inCoroutine()` calls and inline `\Swoole\Coroutine::getCid() > 0` conditions) must EXACTLY equal the pinned map of **26 fork files / 32 arm-fork sites** — a new guarded fork without a covering coroutine-entering test FAILS the guard (S345 rule 3), and a removed fork fails it too, so the map cannot rot in either direction. Coverage state: **14 files newly covered** by S196 fork tests (HTTP-client family: Hub, Metadata, Trakt, Webhook, OAuth2, S3 — driven through the REAL fork decision inside `Swoole\Coroutine\run` with the Workerman event loop reporting running via the new `WithWorkerEventLoop` trait and only the workerman/http-client faked; `ComskipRunner` and `Recorder` sleep forks and `SegmentProcessRegistry::cooperativeSleep()` proven by sibling-coroutine interleave; `MediaAssetWorker` / `SimilarityWorker` fan-out arms and `LibraryMetadataMatcher`'s bounded fan-out proven by their own arm-distinct log lines; `ChapterMarkerService::runCommand`'s `Coroutine\System::exec` vs `shell_exec` arms proven by their measured outcome difference; `RelayConsumer::dispatchWithDeadlineInner`'s deadline-sentinel arm proven by the 504→null vs raw-504 outcome pair), each mutation-proven (arm-flip mutant → named test red → byte-verbatim restore → green; the Hub/HttpClient proof re-run live in the tester pass); **4 files** covered by earlier steps (StunClient S169/170, NatPmpClient/PortForwardService/UpnpIgdClient S197); **8 pre-existing coroutine tests re-measured** with execution evidence (the "possibly covered" 10 minus the 2 DB context-read classes `PhlixMySQLConnection`/`PooledMySQLConnection`, which read `getCid()` as a mutex-ownership assignment — their own tests carry cid-capture evidence): 7 with direct behavioral evidence (sibling-interleave, in-flight counters, fake-client consultation, timer deadline sentinel), 1 (`FfmpegRunner`) with coroutine-execution + result evidence whose arm is NOT mutation-discriminating — recorded as the weakest evidence in the set. No behaviour change, zero `src/` edits; no migration (next-free server migration stays 103). Divergence filed as S403 (allocation requested with this PR): `Coroutine\System::exec` maps an unexecutable command to `['code' => 127, 'output' => '']` while `shell_exec` yields `null` — benign for every current caller, filed rather than patched. - **The S196 divergence is resolved as PINNED — the three-arm shape on an unexecutable command is now a contract (S403).** Measured on PHP 8.3.6 / swoole 6.2.1: `Swoole\Coroutine\System::exec` returns `['code' => 127, 'output' => '']` inside a coroutine, `shell_exec` returns `null`, and the three wrappers (`FfmpegRunner::runCoroutineAwareShellExec`, `FfmpegRunner::runProbeCommand`, `ChapterMarkerService::runCommand`) return `''` inside a coroutine vs `null` on the main stack. `tests/Unit/Support/Coroutine/CoroutineShellExecDivergenceContractTest.php` pins these shapes — invariants and outcome sets only, never the exact exit code (S169/S170 errCode lesson) — and is mutation-proven (flip an arm's expected shape → the named test reds; byte-identical restore → green). Benign for all callers; pinned rather than patched because no code-based rule can align the arms for every input without inventing a new contract (a real command may legitimately exit 127, and `shell_exec` returns output even for non-zero exits). Both divergence records (`ForkInventoryGuardTest`, `ChapterMarkerServiceCoroutineForkTest`) now reference the contract test by name. No behaviour change, zero `src/` edits; no migration. - **The hwaccel segment-builder branch is proven end-to-end with a REAL hardware encode (S354).** `FfmpegRunner::buildHwaccelSegmentCommand()` — shipped by S56/S60 and verified only at the command-string level (no GPU available) — now executes for real, through the REAL `HlsController::serveFile()` route, in `tests/E2E/Media/Transcoding/HwaccelSegmentBuilderE2ETest.php`: the S315 real-Worker harness (`tests/Support/Browser/hls-controller-server.php`, new `--hwaccel=1` / `--hwaccel-seed=software` flags, both off by default so every pre-existing caller is byte-identical) wired to the REAL merged `HwAccelConfig::get()`, producing CMAF fMP4 segments on demand from a real input, served with 200s and correct bytes, with ffprobe stream-level encoder identity (h264_nvenc on this box: profile Main / level 3.0 / yuv420p) and init++fragment demuxer readback. The SOFTWARE-fallback control runs the same builder with the registry seeded software-only (libx264, profile High / level 4.1) — the two arms are told apart by their outputs, not their command text. A pure guard names its skip when /dev/dri is absent OR when the real probe resolves no usable hardware encoder (measured 2026-08-25: GitHub Actions runners carry /dev/dri yet resolve none — the CI branch; fail-loud is reserved for the probe itself malfunctioning), so CI (no GPU) cannot silently skip this proof and cannot red on it either — named KNOWN LIMIT: the local GPU run is the record. KNOWN LIMIT (measured 2026-08-25): h264_vaapi cannot init on this box — `No VA display found for device /dev/dri/renderD128` (the NVIDIA GPUs carry no VAAPI driver) — so the proof runs the builder's real nvenc resolution; a future VAAPI-capable box must re-tune the stream-signature assertions. The W11 "Hwaccel env flake" is re-baselined with the measured profile: the unknown-vendor fallback assertion in `HwaccelProfileFactoryTest` is now derived from the registry it consults (nvenc on this box, software on a GPU-less runner) — the unknown-vendor contract (never resolve to its own nonexistent profile) is unchanged. No behaviour change, zero `src/` edits; no migration (next-free server migration stays 103). - **The per-construction `/tmp/phlix_*_<uniqid>` logger-dir mint is gone — every one of the 23 `createDefaultLogger()` bodies now returns the shared channel logger (S167).** The 23 `src/` classes that `mkdir()`ed a fresh `sys_get_temp_dir()/phlix_<prefix>_<uniqid>` directory on every construction — `AirPlayManager`/`AirPlaySession`, `CastManager`/`CastSession`, `AuthManager`, `SessionManager`, `PlaybackController`, the eight DLNA classes (`ContentDirectory`, `DeviceRegistry`, `RendererDiscovery`, `PlayToManager`, `RendererControlClient`, `AvTransport`, `PlayToSession`, `DlnaServer`), `MediaScanner`/`BookScanner`/`LibraryManager`/`FolderWatcher`, `AudiobookScanner`, `PhotoLibraryManager`, `RokuSession`/`RokuManager` — now route through the shared `LoggerFactory::get(LogChannels::...)` static cache, one cached `StructuredLogger` per channel. That retires 20 distinct logger prefixes (`phlix_media_` was minted by all four media classes), extending the S96-fixed `MusicLibraryManager` shape to the whole family and removing the per-instance inode/directory-entry leak that survived for the life of the worker. A prefix-agnostic guard, `tests/Unit/Media/TempDirMintGuardTest.php`, snapshots the ENTIRE `/tmp/phlix_*` entry set, re-invokes `createDefaultLogger()` on all 23 converted classes, and asserts the set is unchanged — it goes RED if any new `phlix_*` mint appears on a construction path, known prefix or not. The per-op bucket is deliberately left: `phlix_cover_`/`phlix_audiobook_cover_` (BookScanner/AudiobookScanner) and the pre-existing `phlix_backup_`/`phlix_restore_`/`phlix_plugin_` (BackupManager/HttpInstaller) mints fire only when their operation runs and are not on the construction path; of the cover mints only `AudiobookScanner::saveCoverImage()` mints a directory, dormant in the suite — the accepted residual risk, documented in the guard. No migration (next-free stays 103). - **`scripts/skipped-test-names.sh` now evaluates its contract PER RUN, and a multi-run input is refused with every failing run named (S352).** S345's durable-close follow-up replaces the input-wide sums with per-run accounting: the normalised input is segmented at PHPUnit's banners and terminating summary lines, each run's `declared` / `summarised` / testdox / `No tests executed!` fields are judged inside its own segment, and when ANY run fails the contract the name set is NOT written, stderr names EVERY failing run (ordinal, banner, that run's own arithmetic), and the first failing run's exit code wins — so one run's shortfall can no longer be paid for by another run's surplus and net to a code right for the sum and wrong for both runs (KNOWN LIMIT 1 closed). The input-wide tallies still printed on stderr are audit-only and decide nothing. Two more members of that family close with it: exit 5 now rests on the run's OWN testdox glyphs, so stray ` ↩ ` glyphs outside every run's segment — before any PHPUnit banner, or after a run's terminating summary line — are attributed to NO run and can never reach it (AC2a / KNOWN LIMIT 3 closed), and a `--testdox` run together with a skipped SUITE is attributed from the run's own `Skipped: N` — glyph-less suite skips included — so the mixed shape exits 5 with "Do NOT change phpunit.xml" instead of accusing the config, and the suite-skip-only shape (`No tests executed!` with nothing named) is REFUSED instead of exiting 0 with an empty set (AC2 / KNOWN LIMIT 2 closed; a genuinely empty run prints the identical shape and is refused too, with a verify-the-invocation message). The `--testdox` ban is widened to flag the token on any non-comment command line of a workflow that invokes PHPUnit at all — shell-variable, `env:`, `matrix`, composite-action and reusable-workflow `uses:` shapes included (AC3) — and the `/dev/full` fallback asserts the `write_status` CHECK, not the variable name (AC4). The KNOWN LIMITS block in the script header, the README exit-code table and the README `--testdox` paragraph are re-derived to match what the tool now does (AC5); what remains open is a data-set label containing a literal newline (truncated, denominators still agree), a list-HEADER drift (refused as exit 4 blaming `phpunit.xml` rather than exit 3), and the concatenated-run assumption (interleaved parallel jobs split at the wrong boundaries and refuse as exit 2 — pipe each job's log separately with `gh run view --log --job <job-id>`). Guard: `tests/Unit/Support/SkippedTestNameReportingTest.php` (fixtures for every shape above, driving the real `vendor/bin/phpunit`). No migration (next-free stays 103). - **`library:scan`'s exit-code contract is now operator-facing: `setHelp()` and a README table (S347).** The contract (`0` clean scan, `1` did-not-run, `3` completed-with-loss, `2` deliberately unused) used to live only in the class docblock — `configure()` called no `setHelp()`, and no README/docs/CHANGELOG mention stated the codes — so an operator or wrapper script that read `--help` or the README never saw them. `php bin/phlix library:scan --help` now prints the full table under `Help: Exit codes:`, the README `### CLI` section carries the same table, and a new `testHelpNamesEveryExitCode` pins that every code the command can return — 0/1/3 plus the reserved-2 note — is named in the help text. No exit-code renumbering: `3` remains the deliberate choice that avoids Symfony's `Command::INVALID = 2`; the existing 3-way pin (`testScanRendersTheCountersIncludingFailed`, `testTheLossyExitCodeIsDistinctFromTheFailureExitCode`, `testALossyButCompletedScanStillMarksTheJobCompleted`) was re-verified red on a 3→2 mutation and green on restore. No migration. - **A music album with no artist tag is no longer discarded whole — it is ingested under a structural placeholder artist (S331).** `MusicLibraryScanner::flushAlbum()`'s early exit (`if ($artistName === '' || $artistName === 'Unknown Artist') { skip; return; }`) used to drop the ENTIRE album group and count the files as `skipped_no_artist`. That policy is reversed: the album is now ingested under a placeholder artist whose display label is `[Unknown Artist]`. 🔴 **The placeholder is STRUCTURAL, not a bare magic string** — migration `102_music_artists_placeholder_flag.sql` adds `music_artists.is_placeholder` (TINYINT(1) NOT NULL DEFAULT 0) and widens `uk_name` to the composite `(name, is_placeholder)`. Untagged-ness is detected by the ABSENCE of an artist tag (exact comparison against `''` / `'Unknown Artist'`, never `LIKE`); the placeholder row is resolved BY ID through the new `ensurePlaceholderArtist()` (lookup filtered `is_placeholder = 1`); and `upsertArtist()`'s natural-key lookup filters `is_placeholder = 0`. A REAL album genuinely tagged `[Unknown Artist]` therefore resolves to its OWN `is_placeholder = 0` artist row and can never be silently merged into the untagged bucket — proven on a real library (9 real ffmpeg-generated files, real MySQL 8.0.46, `phlix_s331` scratch DB): placeholder `[Unknown Artist]` (is_placeholder=1, 3 albums / 5 tracks) vs real token-tagged artist (is_placeholder=0, 1 album / 2 tracks), distinct ids, both reachable through `MusicLibraryService`'s artist→album→track browse walk with the track resolvable via `findTrackByMediaItemId()` (the `/media/{id}/stream` play path). The summary counter is re-purposed HONESTLY: `skipped_no_artist` is renamed `placeholder_artist_files` (always present, S96(f) shape) and the completion line is INFO again (nothing is skipped; an untagged album is now indexed, and `failed` stays 0 — measured `placeholder_artist_files: 5` for the 3 untitled + 2 tagless files). The discard-whole-album shape is pinned by `testAnUntaggedAlbumIsIngestedUnderThePlaceholderArtistInsteadOfDiscarded` (shown RED on the pre-fix code first), plus real-token-distinctness and placeholder-reuse-across-scans tests; `MusicScannerInlineMediaItemIdCoercionTest`'s inline-site set grew from two to three (`ensurePlaceholderArtist`); `WriteResultAdoptionGuardTest`'s insert-consumer denominator moved 94→95 / 14→15 (the new INSERT adopts `statementWroteNothing()`); `findArtistByName()` gained `ORDER BY is_placeholder DESC, id ASC` so the name-keyed artist detail is deterministic when both rows exist. 🚨 **MIGRATION ANSWER — USED 102 (a column AND a composite unique key); a RESCAN IS REQUIRED**: previously-discarded albums were never written to the database and no backfill can reconstruct them without re-reading the audio files — run `php bin/phlix migrate`, then scan (or rescan) each music library. S240 is a separate music-metadata item and was not touched. - **The S33 per-library "Automatically generate collections from TMDB box sets" toggle is now honored on the photo, book and audiobook scan paths, not just the main movie/series/video path (S327).** `MediaScanner::scan()`'s 6th parameter (`autoCollectionsEnabled`, default `true`) was ignored by the three type-specific call sites — photo (scanner label `image`), book and audiobook all passed THREE arguments, so flipping the stored `autoCollectionsEnabled` flag in the DB changed nothing for those library types. All three now read `LibraryRow::autoCollectionsEnabled()` and pass it explicitly as the 6th argument, so the stored setting finally reaches the same S33 gate the main path always used. Music is deliberately unchanged: its scan is routed to `MusicLibraryScanner` and never reaches `MediaScanner`'s gate at all. Cross-repo: the UI half (phlix-ui) now exposes the toggle for series/video libraries too, whose scans reach this gate through the main path. New `LibraryManagerAutoCollectionsToggleTest` (7 tests) pins the reach set — stored false/true/absent each reach `scan()` with the expected flag for all three fixed types, movie/series/video unchanged, music never reaches `MediaScanner`, and a source-shape guard pins exactly 4 call sites — and goes RED on the pre-fix code. No migration; movie behaviour unchanged. - **LDAP and OIDC settings saves now derive the persisted `plugin_settings.settings_json` from the schema's `properties` instead of a hand-enumerated key literal, so a schema addition can no longer be silently dropped (S337).** `LdapAdminController::saveSettings()` and `OidcAdminController::saveSettings()` built the replacement document from a key list that matched `getSchema()`'s enumeration plus the write-only secrets (`bind_pw` / `client_secret`) only by accident — the same defect class as S332 a week earlier. A shared private `settingsSchemaProperties()` is now the single enumeration for both `getSchema()` and `saveSettings()`; per-key `normalizeSavedValue()` normalises each property, and its `default` arm throws a `LogicException` naming the exact fix site, so a schema property without a normalizer fails loudly instead of being dropped. The write-only secrets keep the documented blank-keeps-existing rule generically via the schema's `writeOnly: true` marker, and OIDC `redirect_uri` absent-key preservation is byte-identical to pre-S337 (body trimmed at the input boundary; preserved values verbatim). New guards: `AuthProviderSettingsKeySetConsistencyTest` (emitted key set equals the schema property set in both directions, both printed on failure) and `AuthProviderSettingsSchemaRoundTripRealDbIntegrationTest` (every property survives a real-MySQL round-trip; a schema-foreign body key is not persisted). `GithubAdminController::saveSettings()` keeps the same hand-literal shape, measured and out of scope; S117 is untouched. No migration (next-free stays 102). - **Both remaining `ScanJobRepository` INSERT-result consumers now adopt `WriteResult::wroteNothing()`, and an adoption guard keeps the whole inventory honest (S342).** `enqueueIfNoneActiveOfType()` (`if ($result !== null)` → `if (!WriteResult::wroteNothing($result))`) and `startRunningIfIdle()` (`return $result === null ? null : $id;` → `return WriteResult::wroteNothing($result) ? null : $id;`) were the two hand-rolled bypasses S131's AC audit re-derived: the first landed eight days BEFORE S131 and was missed at ship time, the second four days after it. Both are behaviour-preserving for every reachable input — `null` is the client's answer for a zero-row `INSERT … WHERE NOT EXISTS` refusal, `'0'` (falsy!) is a successful insert into the CHAR(36)-PK `library_scan_jobs` table, and the `false` arm the client never produces narrows to "wrote nothing" exactly as the helper does everywhere. New `WriteResultAdoptionGuardTest` token-scans every `->query()` in `src/` whose statement literal begins `INSERT`/`REPLACE` and whose result is consumed, asserting the consumer is the helper (or the named exception, `StreamSessionService::updateStreamLimit()`, carried with its reason) — the enumeration guard S131 never built, with its denominator (94 call tokens / 14 consumed) pinned so a scan that examined nothing cannot pass silently. `WriteResult`'s class docblock, which cited `startRunningIfIdle()` as an interpretive example as though the site were already an adopter, now names both adopting sites. - **Connection-pool exhaustion under oversubscription is fixed by per-query borrow outside transactions (S339).** The pool's acquire timeout could fire (`pool exhausted: no idle connection available after 10 s`) with only `maxSize` queries in flight: `PooledMySQLConnection` held each coroutine's lease for the coroutine's WHOLE lifetime (returning it only via `Swoole\Coroutine::defer()` on coroutine exit), so under oversubscription a waiter had to survive a holder's entire query loop, and a slow enough holder — or slow enough box — tripped the timeout. The lease is now returned to the idle pool at the START of the coroutine's next DB call (lazy return) whenever no transaction is open, so the hold window is the gap between consecutive DB calls rather than the coroutine lifetime; transactions keep their lease for the whole transaction (affinity preserved), and `lastInsertId()` deliberately bypasses the lazy return so it always resolves on the connection that performed the preceding INSERT even when waiters are parked. `$acquireTimeout` (constructor, default 10.0) makes the exhaustion behavior configurable, and a new `poolStats()` exposes the pool's own counters (`maxSize`/`created`/`leased`/`idle`) for observability. The S180 assertion-escape probe-baseline exclusion for `PooledConnectionConcurrencyTest::runChurn` is removed: the site now probes deterministically `GATES` (5/5, was 18 GATES / 1 NOT-REACHED plus 3/20 bare failures), and the exclusion list is pinned at zero. - **The PHPStan `src/` gate now carries zero suppressions, and a test asserts it stays that way (S343).** `phpstan.neon` held three stale suppressions — two `excludePaths` entries (`src/Server/WebSocket/Events.php`, `src/Server/Arr/WorkermanArrTransport.php`) and one `reportUnmatched: false` `ignoreErrors` rule for a `RadarrClient` constructor call — that excused nothing (`src/` measured `[OK]` at level 9 with and without `ext-swoole`), and the ignore rule's `reportUnmatched: false` meant PHPStan could never flag it as dead: an ignore list that cannot self-clear is a baseline by another name. All three are deleted, `phpstan.neon.dist` is brought back into line (its own stale `excludePaths` entry too), and `StaticAnalysisScopeTest` now asserts `ignoreErrors: []` and an empty `excludePaths` allow-list on the effective `src/` config — the full invariant set the test's name and docblock always claimed. - **`TrickplayController` now consumes `FfmpegRunner`'s single-sourced trickplay filenames instead of re-spelling the literals (S349).** The five sites the S344 sweep found — `getSpriteUrl()` (`…/sprite.jpg` URL build), `getTimelineUrl()` (`…/timeline.json` URL build), `getSpriteContent()` (`$jobDir . '/sprite.jpg'` read path), `getTimelineContent()` (`…/timeline.json` read path) and `getSprite()` (`!file_exists($jobDir . '/sprite.jpg')` 404 fallback) — now reference `FfmpegRunner::SPRITE_FILENAME` / `FfmpegRunner::TIMELINE_FILENAME` (imported via `use Phlix\Media\Transcoding\FfmpegRunner;`), the constants S284 single-sourced at `FfmpegRunner.php:2685,2688`. S284's own docblock warned that a second spelling "would silently stop matching the moment this one moved"; a rename in `FfmpegRunner` can no longer leave this controller serving dead paths. Behaviour is byte-identical (the constants hold the same values). New `TrickplayControllerLiteralsTokenizedTest` (3 tests) token-scans ONLY `TrickplayController.php`, comment-stripped via `token_get_all()` (`MaintenanceTaskCoverageTest::stripComments()` precedent): negative — neither literal appears in the executable source; per-site — each of the five named methods' bodies references its constant; anti-vacuity — all five method names plus `FfmpegRunner::SPRITE_FILENAME` ≥ 3 / `FfmpegRunner::TIMELINE_FILENAME` ≥ 2. Proven RED by mutation (re-spelling `getSpriteContent()`'s site → fail → restore → green). Out of scope, deliberately untouched: the `sprite.png` fallback literals (no constant exists) and the `/trickplay/{jobId}/…` route literals in `Application.php`. No migration (next-free stays 103). - **The one-member-room `playback_sync` round-trip is now proven in the SyncPlay E2E suite (S294).** `testOneMemberRoomPlaybackSyncReachesHost` shows the solo host receiving its OWN host-stamped `syncplay_playback_sync` broadcast back, carrying the authoritative group position (ms) and playback state; `testPlaybackSyncRequestByMemberIsStampedWithHostId` shows a non-host member's request answered with exactly one frame stamped with the HOST id, not the requester's self-asserted id — the property `@phlix/syncplay`'s self-frame consumption relies on. The client half (phlix-syncplay, S294) consumes the host's own frame, so a one-member room re-anchors. Test-only change: zero `src/` edits, no migration (next-free stays 103). - **The three SSDP residue defects S51 deliberately left are closed (S297).** (1) *Target-set single source*: the periodic `NOTIFY` now emits one datagram per advertised target from the responder's single target enumeration, with `USN` paired per target exactly as search responses pair `ST` with `USN` — a passive control point and an active one now see the same device, where previously one saw a single target and the other saw five. (2) *Real multicast join*: `SsdpSocket` was "joining" 239.255.255.250 with an outbound-interface selector under a comment claiming to join; it now uses the S296-proven multicast-join option spelling, proven by a three-arm real-datagram test in which the pre-fix spelling receives ZERO datagrams while the production join receives. (3) *Spawn gate*: `start.php` now forks the SSDP advertiser only when BOTH `dlna.cds_enabled` and `dlna.enabled` are on — the same two-switch rule the runtime gate uses — so a CDS-disabled install (the shipped default) no longer forks an idle worker whose listen socket squats `udp://0.0.0.0:1900`. The gate decision is unit-tested; the real-boot proof (cds_enabled off → :1900 free, on → worker binds) is in the S297 record. Alongside the join, `search()` now collects only HTTP response-shaped datagrams, so NOTIFY/M-SEARCH noise on the now-heard multicast group cannot surface as discovered devices (including self-discovery). No migration (next-free stays 103). - **S120 doc corrections, each contradicted by execution.** - `failOnPhpunitWarning` **defaults to `true`** (`vendor/phpunit/phpunit/phpunit.xsd:184`; `.../TextUI/Configuration/Xml/Loader.php:833`), so "`phpunit.xml` does not set it, therefore a subscriber cannot change the exit code" — asserted in `scripts/assertion-escape-check.php` and `AssertionEscapeGuardExtension` — was wrong in both halves. A subscriber that throws makes an otherwise-green run exit 1. The separate check script remains the right design, but for legibility, not necessity. - The swallowing mechanism is wider than the documented `catch (\Throwable)` / `catch (\RuntimeException)`: `ExpectationFailedException` reaches `\Exception`, and a `return` inside a `finally` discards the exception with no `catch` at all. All four shapes were planted and confirmed swallowed, so a grep for the two catch types is not a sound search for this defect. - **A healing `rescan` no longer rewrites every row it reads** (S148). S145 implemented the full-read mode by *not loading* the S122(a) skip index. That forced every file to be opened, which was the point — but it also left `isStampCurrent()` unable to suppress anything, so the scan issued one `JSON_SET` stamp `UPDATE` **per file read, writing back the values already stored**: 61,135 no-op row rewrites per healing pass on the production music library. The mode is now expressed as a `canSkip()` gate instead: the index is loaded and consulted for the *stamping* decision, while the *read* is forced. Reading every file and rewriting every row were never the same thing. **The reach guarantee is unchanged and is asserted alongside every write count** — a retagged track filed under the wrong album can only be repaired by opening the file, because the skip `continue`s before `probeMetadata()`. A full read of an unchanged library now opens and tag-reads every file and issues **zero** stamp UPDATEs, proven against real MySQL by counting the statements the server received. Two smaller repairs in the same area: a retagged N-track album issues **one** recount of the album it vacated instead of N identical ones (`refreshAlbumTrackTotal()` moved out of the per-track path into the flush's `finally`, deduped by album id), and the `reparented` summary counter no longer sits behind a guard that exists only for that recount, so a track that moved off an unreadable album id is still reported as moved. **Every recount in that `finally` is attempted independently.** Moving the vacated recount out of the per-track `try`/`catch` and into the flush put it *behind* the flushed album's own recount, so a DB failure on the first one silently skipped the rest — leaving the emptied album advertising a `total_tracks` it no longer owns, permanently (it is never flushed again) and while the scan still reported `failed = 0`. `MusicLibraryService::getArtistWithAlbums()` sums that column. Each id now runs in its own `try`, and the first failure is re-thrown once all of them have been attempted, so what gets logged is unchanged. - **The music scanner's per-file existence lookup now uses the `path_hash` index that already existed** (S151). It binds `path_hash = ?` alongside the kept `path = ?`, so the statement resolves as a `const` single-row lookup instead of hand-filtering the whole `library_id` partition — measured on production at `rows 48,512` / 0.71–0.86 s per file before, versus `rows 1` / 0.22–0.52 ms after, on a scan that runs the lookup 61,122 times. MySQL will not make that substitution itself: generated-column index substitution fires only on the column's exact generating expression, which `path = ?` never is. ⚠ **The speed-up requires `idx_media_items_library_path_hash`, which `migrations/` alone did NOT create** — migration 087 drops it and only the manual `php migrations/cleanup_072.php` re-added it. On an install that skipped that finalizer the new statement plans exactly as the old one did. Check with `SHOW CREATE TABLE media_items`. **Fixed by S152's `migrations/096_path_hash_unique_index.sql`** (see Fixed, above): the chain now adds the index itself, so the `const` plan holds on a fresh install. The lookup keeps a **raw-path second pass** for the same reason `ItemRepository::findByPath()` has one: `path_hash` is a generated column, and on a database where migration 087 has not run it is NULL for every track, which would make a hash-only lookup miss every file and mint a duplicate library. The fallback runs only on a miss, so a rescan never pays for it. - **Row-sized backdrop on the media LIST shape** (S101, unblocks phlix-ui S69's wide-backdrop library view). `MediaItemShaper::shape()` — the single shaping point behind `GET /api/v1/media` and every other list surface (most-watched, continue-watching, chapter search, user-data, watch history) — now emits two new keys: - **`backdrop_url`** — the item's landscape backdrop, TMDB-width-swapped from the stored `/w500` up to **`/w780`**. Non-TMDB backdrops (fanart.tv, a locally-cached file) have no width ladder and pass through exactly as stored; `null` when the item has no backdrop art. - **`backdrop_srcset`** — a stored `metadata_json.backdrop_srcset` when `ArtworkStorage` has cached one (the same precedence `poster_srcset` already uses, so a locally-cached backdrop keeps its ladder instead of losing it for not being a TMDB URL), otherwise exactly **two** derived candidates, `w780 780w, w1280 1280w`. `w780` covers a full-bleed phone row at 2× DPR (390 CSS px × 2) and a 780 CSS px row at 1×; `w1280` covers a ~1280–1400 CSS px desktop row at 1× and is the largest TMDB step below `/original`. `null` when there is neither a stored srcset nor a TMDB width ladder → the client uses `backdrop_url`. Both keys pass a **URL scheme allowlist** — absolute `http`/`https` and app-relative paths (`/api/v1/artwork/{id}?size=…`) only. `metadata_json` image URLs are provider-, `.nfo`- or plugin-supplied, and this step emits them on up to 100 rows per response, so `javascript:`/`data:` URIs, protocol-relative `//host/…` and values carrying an attribute-breakout character (`"`, `'`, `<`, `>`, backtick, backslash) or a raw control byte now become `null` instead of being echoed — and, in the srcset's case, width-swapped and embedded. The same allowlist is now applied to the **detail** shape's `backdrop_url` (`shapeDetail()`), which was previously unvalidated even though it is the value a client paints as a full-bleed background and fans out into `backdrop_url_large` plus three srcset candidates. A stored srcset is validated on **both** halves of every candidate — the URL against the allowlist and the descriptor against `<int>w` / `<float>x` — and the emitted value is **reconstructed** from those validated pairs rather than passed through, so a space-free payload parked after the descriptor (`…/bg.jpg 780w"><svg/onload=…>`) and a URL carrying an interior space (which a browser reads as one URL plus two descriptors) are both rejected. One bad candidate rejects a whole stored srcset. Stored URLs are also **trimmed** before parsing: a whitespace-padded URL previously reached the client verbatim *and* silently lost its width ladder, because the `^`-anchored TMDB regex rejects the leading space. Only leading and trailing space/tab/CR/LF count as padding — any other control byte, including an edge NUL or vertical tab that PHP's default `trim()` would silently swallow, is rejected before trimming. **`/original` is deliberately NOT advertised on list rows, and the detail-only `backdrop_url_large` is NOT emitted there.** `GET /api/v1/media` returns up to `PageLimit::MAX` (100) rows per page; a TMDB `/original` backdrop is a 3840×2160-class JPEG (typically 1.5–4 MB), so a single page of hero-sized backdrops would be 150–400 MB of image traffic for a ~300 px-tall strip that can never use 2160 lines of detail. **Measured payload cost, per encoder.** On the wire — the figure that actually matters — the resident `HttpHandler` gzips JSON at level 6 whenever the client sends `Accept-Encoding: gzip`, so a realistic 100-row page (80 % of rows carrying a backdrop) grows by **on the order of 2 KiB — under 3 KiB in every measurement taken** (three independent synthetic pages: +2 999, +2 745 and +1 779 bytes, i.e. +30, +27 and +18 bytes per item). Read that as an order of magnitude, not a bracket: the gzip delta **scales with how compressible the rest of the page is**, because the more filler a page carries the better gzip folds the repeated TMDB URL prefixes into it — the three page baselines (13 413 / 7 199 / 6 894 bytes) order exactly as their deltas do. The uncompressed per-item figures that follow are the authoritative, reproducible numbers. Uncompressed, `Response::json()` encodes with `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES`, and the in-page cost there is **+270 bytes per item** with a TMDB backdrop (+26.4 KiB per 100-item page), **+153 bytes** with a fanart.tv backdrop (no width ladder, so `backdrop_srcset` is `null`) and **+71 bytes** for an item with no backdrop at all — the common case for music/photo/book rows. The same three cases in *compact* encoding are +242 / +125 / +43 bytes. Had `shapeDetail()`'s three fields simply been copied instead, the pretty in-page cost would have been **+450 bytes per item (+43.9 KiB per page)**, 1.7× this shape. (`public/index.php` does not gzip; the resident worker, which serves production, does.) No image resizer is introduced (that is S71–S73); only existing TMDB size variants are used. `shapeDetail()`'s size budget is unchanged — it still overwrites both keys with the hero budget (stored URL + `/original` + the 3-step srcset) after calling `shape()`, now with the stored URL run through the same allowlist, which is the identity function on every URL that passes. So for every legitimate **clean** row the detail/player response is **value-identical (key order shifts)**: every value is exactly what it was and the response length is unchanged, but because the keys now arrive through `array_merge()` they sit earlier in the JSON object than before. Object key order carries no meaning for a JSON consumer — the `md5` of the raw response differs, the `md5` of the `ksort`ed response does not. "Clean" is the one carve-out, and it is the trimming described above rather than a loss: a **whitespace-padded** stored `backdrop_url` used to ship verbatim with `backdrop_url_large`/`backdrop_srcset` `null`, and now ships trimmed with the `/original` + 3-step ladder it had silently lost. Both HTTP entry points (`public/index.php` and the resident `HttpHandler`) resolve the same `WebPortalRouter` and therefore both emit the new keys; no constructor or DI wiring changed. Signed-URL freshness follows the established pattern: both values run through `SignedUrl::refreshArtworkUrl()`/`refreshArtworkSrcset()` at **response** time, so once backdrops are cached locally (S72) a stored scan-time signature can never be served expired. Types with no landscape art (track/music/album/artist/photo/book/audiobook) carry no `metadata_json.backdrop_url` and degrade to `null` on both keys — there is no `type` allowlist, because fanart.tv genuinely supplies artist/album backgrounds. New `BackdropSrcset::rowUrl()` / `::rowSrcset()` carry the row budget, and the hero and row budgets now share ONE width ladder and ONE srcset code path — the hero srcset is literally the row srcset with the `/original` candidate appended (`forBackdropUrl()` delegates to `rowSrcset()`), so the two can never drift. - **Sign in with GitHub, and DB-backed provider settings** (updates.md #54 / S48). The first concrete non-OIDC OAuth2 provider, proving the S44–S47 foundation: - **`GithubOAuthProvider` + `GET /auth/github/authorize` and `GET /auth/github/callback`** (both unauthenticated — the GitHub redirect carries no Phlix session). Plain authorization-code + **PKCE**, no OIDC discovery and no `id_token` assumption, profile via `GET https://api.github.com/user` (default scopes `read:user user:email`), and account resolution through the S46/S47 `user_identities` path so a GitHub identity is scoped to `provider='github'`. Enabled by the `auth.github.enabled` server setting like the other bundled providers and registered per worker by `AuthProviderBootstrapper`; linking an existing account uses `GET /auth/identities/link/github` (unlink stays the provider-generic `DELETE /auth/identities/{id}`). The shared `Phlix\Plugins\OAuth2\AbstractOAuth2Provider` base carries the reusable OAuth2 + PKCE machinery, so the next provider family is a subclass rather than a copy. - **Provider settings now live in the DATABASE, not `settings.json`** — new **`plugin_settings`** table (migration **`093_plugin_settings.sql`**: one row per plugin, `plugin_name` PK + `settings_json`) behind a `PluginSettingsStore` interface, consumed through the `PluginDbSettings` trait. Resident Workerman workers may run on a read-only/ephemeral filesystem and a per-plugin file is invisible to the other workers, so a shared row is the correct home. **No operator action is needed on upgrade:** a plugin with no row yet performs a ONE-TIME lazy import of its legacy `settings.json`, and the file store remains the fallback when no DB store is injected (unit tests / no DB). Deliberately NOT the catalog `plugins` table — a catalog row would double-register the bundled providers. Migration 093 is `CREATE TABLE IF NOT EXISTS` and is **idempotent on the paths that bypass the `schema_migrations` checksum ledger** too (a raw `mysql <` replay, or a database whose ledger row was lost) — verified on a scratch MySQL 8.0 to re-apply with 0 errors and to leave existing settings rows untouched. - **Admin config endpoints for GitHub** — `GET`/`POST /api/v1/admin/auth-providers/github/config` plus `GET .../github/schema` (admin-only), alongside the provider-generic `POST /api/v1/admin/auth-providers/github/{enable,disable}`. The read endpoint never echoes `client_secret`, and `configured` means *id AND secret present* — the same bar `AuthProviderBootstrapper` uses to build the provider, so the UI cannot report "configured" and then have `enable` answer `409 not_configured`. **There is no GitHub card in the admin SPA yet**, so configure it through these endpoints (see the SSO guide in phlix-docs for copy-paste calls). - **OAuth2 state is browser-bound.** `DbOAuth2StateStore` (the `oauth_state_store` table, 600 s TTL, atomic one-shot consume — never `$_SESSION`, which is process-global under Workerman) plus `StateCorrelation`: a 32-byte secret in an HttpOnly + Secure + SameSite=Lax cookie (`phlix_oauth_github` / `phlix_oauth_oidc`) whose SHA-256 is the only copy persisted in the state context, compared with `hash_equals()` **before** any token exchange, account link or session-cookie mint. This closes a login-CSRF / session-fixation path where an attacker ran the authorize leg and then had a victim's browser deliver the callback (`SameSite=Lax` does not restrict `Set-Cookie`). Applied to the OIDC flow too. - **DLNA renderers can finally play something: `GET|HEAD /dlna/stream/{mediaItemId}`** (updates.md #44 / S52). Until now DLNA *browse* worked while DLNA *playback* could not, by construction: every byte-serving route in Phlix demands a session or an HMAC-signed URL, and a DLNA renderer is a dumb HTTP client that can present neither — it fetches exactly the URL the ContentDirectory advertised. This adds the one route that carries no token. - **Gated by the DLNA IP allowlist and by nothing else.** The route is registered *inside* `loadCdsRoutes()`'s existing middleware group, so `DlnaAllowlistMiddleware` (`dlna.allowed_cidrs` / `dlna.restrict_to_lan`, default: loopback + RFC1918/ULA/link-local only) is evaluated per request on it, exactly as on the SOAP endpoints. An off-allowlist caller gets **403** before the handler runs. It is deliberately NOT served from `HttpHandler` like `/media/{id}/stream` is — that path dispatches before the router and router middleware cannot reach it, so a route built that way would have had **no** allowlist enforcement at all. - **`dlna.cds_enabled` still ships OFF**, and while it is off the route is not registered, so it **404s rather than 403s**. Turning DLNA on is what exposes media; nothing changes for an install that leaves it alone. Note that this changes what that switch *means*: before 1.7.0 it exposed metadata whose `<res>` URLs 404'd, so nothing was playable. - **Range requests** (`bytes=A-B`, `bytes=A-`, `bytes=-N`) are honoured so a renderer's scrub bar works: 206 + `Content-Range`, an over-long last-byte-pos clamped to EOF per RFC 7233 §2.1, an unsatisfiable range **416** with `Content-Range: bytes */size`, multi-range falling back to a whole-file 200, and `Accept-Ranges: bytes` on every served reply. Bytes stream through the Workerman event loop (`withFile()`, chunked above 2 MB), so a multi-GB file never lands in worker heap. - **Direct play only.** The source file is served as-is; a container this server does not recognise is answered **415** rather than bytes under a guessed type. No transcode is started, deliberately: the only on-demand pipeline is HLS (`.m3u8` + MPEG-TS), which no DLNA renderer speaks, so triggering one would swap a dead URL for an unplayable one. Not direct-playable today: `.iso`, `.vob`, `.divx`, `.asf`, `.rmvb`, `.mpv`, extension-less files, and all book/audiobook items. A listed container whose *codecs* the device cannot decode (HEVC/10-bit, AC-3/DTS/TrueHD, PGS subtitles) is still served and will fail on the device — withholding those from `<res>` is a follow-up. - **Path safety.** The route takes an id, never a path; the id must match `/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/` before it is used for anything; the path comes only from the `media_items` row and is `realpath()`'d before any filesystem access; and a new `LibraryRootJail` requires the canonical result to sit inside a configured library root (trailing-separator-exact, so `/media/movies-private` never matches `/media/movies`, and canonical-first, so a symlink out of a library is refused). It **fails closed** — no resolvable roots denies everything. Every refusal is one indistinguishable `404` with no filesystem detail in the body or headers; the only deliberate exception is the 415, which a renderer needs told apart from "no such object". - **The relay tunnel cannot reach any of it.** `RelayRequestDispatcher` now hard- denies `/dlna/*`, `/cds/*`, `/scpd/*` and `/description.xml` with a 404. `RelayConsumer` stamps `127.0.0.1` as the peer IP on every relayed frame (a relayed request has no meaningful TCP peer) and loopback is on the LAN allowlist, so without this a request arriving from the internet over the tunnel would have been handed the LAN policy and admitted with no token. A DLNA renderer is by definition on the local network and never arrives via the relay, so nothing legitimate is lost. - ⚠️ **Parental controls and per-profile stream limits DO NOT apply to DLNA.** There is no signed-in user on a DLNA request, so there is no profile, so there is no rating filter and no stream cap to enforce: any device the allowlist permits can browse and play content a profile's rating filter would hide in the app. This is not new — DLNA browse has never filtered by rating — but this release makes it *playable* rather than merely listed. There is no per-profile fix (there is no profile); a **server-wide** `dlna.max_rating` excluding over-cap items from DLNA browse is the enforceable form and is not implemented yet. Documented at the switch in `config/dlna.php`. - **Unlink an identity, log in *via* a linked identity, and multi-provider foundation** (updates.md #55 / S47). Completes the account-linking story started in S45 and lays the multi-instance groundwork: - **`DELETE /auth/identities/{id}`** (**authenticated**) — a user removes one of their OWN linked external identities. The `{id}` is the `id` returned by `GET /auth/identities`. Guards: - **own-identity-only** — the target is resolved within the caller's own identity list (keyed on the trusted session `user_id`), so an id owned by another account (or a non-existent one) returns an indistinguishable **`404 identity_not_found`**, never a cross-account delete; - **never remove your last sign-in method** — a request that would leave the account with **no local password AND no other linked identity** is refused with **`409 last_sign_in_method`**, so a user cannot lock themselves out. The local password and every OTHER linked identity are left untouched. Success is `200 {success, message}` (missing/empty id → `400`, unauthenticated → `401`). - **Login *via* a linked identity now works** — this **lifts the S45 limitation**. `UserRepository::findOrCreateByExternalId()` now resolves the owning user through the `user_identities` table FIRST (`provider`, default-instance `''`, `external_id`), so an identity you LINKED to an existing account (S45) can now be used to log in and resolves that existing account instead of wrongly creating a duplicate. The legacy `users.provider` / `users.external_id` lookup remains as a belt-and-suspenders fallback for any un-backfilled row, and the create path keeps the S46 dual-write, so **no existing/legacy external user loses login**. New `UserRepository::hasLocalPassword()` backs the unlink guard. - **Multi-instance provider capability.** `AuthProviderRegistry` now keys on a composite INSTANCE KEY (`AuthProviderRegistry::instanceKey()`): the default instance (the `''` sentinel, matching `user_identities.provider_instance`) is the family name verbatim, and a named instance is `family:instance`. This lets two providers of the SAME family (e.g. two OIDC issuers) coexist in one worker's registry without the previous duplicate-name throw — the platform-level foundation for configuring multiple OIDC/OAuth providers. **No admin UI or seeding of named instances ships in this step** (an operator/admin concern for a later step); every pre-S47 single-instance caller is behaviour-unchanged because the default-instance key equals the family name. - **Centralized auth-provider route registration.** New `AuthProviderRouteRegistrar` is the single, unit-tested site that wires every external-auth route (OIDC authorize/callback + the `/auth/identities` group incl. the new unlink), called once from `Application.php`. This closes the S44 class of bug where a fully-implemented provider (OIDC) shipped with its routes never wired. - **Account linking — attach an OIDC/LDAP identity to your logged-in account** (updates.md #55 / S45). An already-signed-in local account can now link an external identity and list what is linked. Three new **authenticated** endpoints (registered in `Application.php`'s auth group; the current user is read from the validated session, never from the request body): - **`GET /auth/identities`** — list this user's linked external identities. Returns `{identities: [{id, provider, provider_instance, external_id, linked_at}]}`. `provider_data` is **deliberately never returned** and no provider secrets are exposed. - **`GET /auth/identities/link/oidc`** — start linking an OIDC identity. Runs the same authorization-code + PKCE flow as OIDC login, but with a server-side **link intent**: the initiating user's id is bound into the integrity-protected **server-side** OIDC state context (`OidcStateStore` gained an optional `context` carrying `intent=link` + `link_user_id`), **not** the client-visible `state` param. The existing unauthenticated `GET /auth/oidc/callback` gained a **link branch** that, when it sees `intent=link`, attaches the IdP-verified `sub` (`external_id = oidc.<sub>`) to `link_user_id` and `302`s back to the same-origin app with `?linked=oidc` — **no login session is minted** and the callback ignores any client-claimed `external_id`. - **`POST /auth/identities/link/ldap`** — link an LDAP identity. Body `{username, password}`; only a **successful LDAP bind** links the provider-returned `ldap.<dn>` to the current account. A failed bind links nothing and returns a single generic `401` (no user-enumeration oracle); a genuine directory/config failure returns `503`. Success returns `200` `{success, provider, created, message}`. - **Security model (the linchpin).** Linking **always** proves control of the external identity via the real provider (a claimed identifier is never trusted: OIDC via the IdP round-trip + id-token validation, LDAP via a live bind). Linking **never** changes your primary login and **never** mints a new session, and `users` is never mutated. An external identity already linked to a **different** account is rejected with **`409 identity_already_linked`**; an identity already linked to the **same** account is an idempotent success (`created:false`). The `user_identities` DB UNIQUE index (migration 092) is the race backstop — a duplicate-key throw on insert is re-classified by re-reading the row and mapped to `409`/idempotent success **only** when the identity genuinely exists; a non-duplicate insert failure is re-thrown as a real `5xx`, never a mislabeled `409`. - **Storage.** A link is a `user_identities` row (S46 / migration 092) for the current `user_id`; `provider_instance` is the single-instance sentinel `''`. - **Now delivered in S47.** **Unlinking** an identity (`DELETE /auth/identities/{id}`) and **logging in *via* a linked identity** (the login read path now resolves through `user_identities`) shipped in **S47** (see the S47 entry above) — a freshly-linked identity is now usable for login and resolves your existing account. The multi-instance registry foundation (non-empty `provider_instance`) also landed in S47, though configuring multiple named provider instances from the admin console remains a later step. - **`user_identities` table + multi-identity auth foundation** (updates.md #54 / S46). Internal schema plumbing for account-linking and multi-instance external auth — **no user-facing behaviour change yet** (login is unchanged; linking arrives in a later step). Details: - **Migration `092_user_identities.sql`** creates a row-per-identity join table `user_identities (id, user_id FK → users ON DELETE CASCADE, provider, provider_instance NOT NULL DEFAULT '', external_id, provider_data JSON, created_at, updated_at)` with `UNIQUE (provider, provider_instance, external_id)` and an index on `user_id`. It **applies automatically** via the migration runner on deploy — no manual step. `provider_instance` is `''` (a non-NULL sentinel, so the multi-column UNIQUE actually enforces one identity per default instance) for all single-instance identities; non-empty instance names are reserved for a later multi-instance step. - **Backfill** copies every existing external identity from `users` (where `external_id IS NOT NULL`) into `user_identities`, **deriving the real provider** (legacy `provider='external'` rows become `oidc`/`ldap` from the `external_id` prefix; unknown prefixes fall through unchanged, never dropped) and collapsing any pre-existing duplicate external accounts to a single identity row via `INSERT IGNORE`. It is idempotent (per-user `NOT EXISTS` guard) so re-running after a partial failure is safe. Existing external-identity users keep logging in exactly as before — the backfill does not touch `users`. - **New `Phlix\Auth\UserIdentityRepository`** (create / findByProviderExternalId / findByUserId / delete / deleteById), wired via `AuthServicesProvider`. - **`UserRepository::findOrCreateByExternalId()` now dual-writes** a matching `user_identities` row (default-instance `''`) inside the SAME transaction that creates a new external user, so the two stores can never diverge (a failure rolls back the `users` row too). `users.provider` / `users.external_id` remain the **authoritative login-lookup columns** — repointing login reads onto `user_identities` is deferred to a later step. Returning-user (find) logins write no new identity row. - **OIDC & LDAP external login are now wired end-to-end** (updates.md #37 / S44). The admin **Integrations → Auth providers** enable/disable toggles for OIDC and LDAP were hard-coded `501 Not Implemented` stubs and neither provider had a reachable login route. S44 makes both usable: - **Enable-state = server settings flags.** `AuthProviderController::enableProvider()` / `disableProvider()` now persist `auth.oidc.enabled` / `auth.ldap.enabled` via `SettingsRepository` and (de)register the provider in the worker's `AuthProviderRegistry` (new `AuthProviderBootstrapper`). Responses are honest: `200` with `{enabled, live}`, `409 not_configured` when the provider has no saved settings yet, `404 unknown_provider` for a non-toggleable name. Enabled+configured providers register per-worker at boot (`start.php`, no boot-time network I/O) and **self-heal on the request path**, so enabling takes effect without a full restart. - **OIDC authorization-code flow** is served by `OidcCallbackController`, now routed via `Router::oidcAuth()` in `Application.php` (unauthenticated): `GET /auth/oidc/authorize` starts the flow (PKCE + `state` + `nonce`) and `GET /auth/oidc/callback` validates the returned id-token (signature / `iss` / `aud` / `exp`), mints a Phlix session, and 302s back to the caller. Register `<your-server>/auth/oidc/callback` as the redirect URI at your IdP. - **LDAP login** rides the existing `POST /auth/login`: send an `ldap:`-prefixed username (e.g. `ldap:jdoe`) plus password to authenticate against the configured directory (`AuthController::ldapLogin()` → `AuthManager::loginWithProvider()`). - **Async OIDC HTTP.** New `Phlix\Plugins\Oidc\OidcHttpClient` (workerman/http-client with a TLS-verifying cURL fallback) replaces blocking `file_get_contents`/`curl_exec` in OIDC discovery, token exchange, userinfo, and JWKS fetches so the resident worker no longer stalls. LDAP remains a bounded 5 s blocking call (ext-ldap is not Swoole-hookable); the limitation is documented in `LdapConnection`. Provider config (OIDC `provider_url` / `client_id` / `client_secret` / `scopes`; LDAP `host` / `port` / `ssl` / `base_dn` / `bind_dn` / `bind_pw` / `user_filter` / `admin_group`) is still edited through the existing OIDC/LDAP admin config UI (`OidcAdminController` / `LdapAdminController`, `POST /api/v1/admin/auth-providers/{oidc,ldap}/config`); DB-backed provider settings are a future step. See the SSO/external-auth guide in phlix-docs. - **Relay tunnel TLS controls** (updates.md #38 / S38). Three new `config/relay.php` keys plus matching environment variables let an operator opt the server↔hub relay tunnel into TLS explicitly, independently of the server's public HTTP TLS: - `relay_tls` / **`PHLIX_RELAY_TLS`** (default `false`) — when truthy (`1`/`true`), the *derived* relay scheme becomes `wss://` and the connection is opened with `transport='ssl'`. Mirrors the hub's `HUB_RELAY_TLS` flag; a TLS relay deploy requires **both** `PHLIX_RELAY_TLS=1` on the server **and** `HUB_RELAY_TLS=true` on the hub. - `relay_tls_verify` / **`PHLIX_RELAY_TLS_VERIFY`** (default `true`/secure) — set to `0` to accept a self-signed hub relay certificate (`verify_peer=false` + `allow_self_signed=true`, mirroring the hub's permissive relay server context). - `relay_tls_cafile` / **`PHLIX_RELAY_TLS_CAFILE`** (default `/etc/ssl/certs/ca-certificates.crt`) — CA bundle used to verify the hub's relay certificate when verification is on. An explicit `hub_relay_ws_url` / `PHLIX_RELAY_HUB_WS_URL` remains the highest-precedence override (and is no longer overwritten by `withAutoEnable()`); when it points at a `wss://` URL it should be paired with `PHLIX_RELAY_TLS=1` so the cert/verify keys apply and the start-time mismatch heads-up stays quiet. - **`RelayStateStore`** (updates.md #38 / S38). New `Phlix\Hub\RelayStateStore` persists the relay tunnel's last connect state (connected/active, reconnect attempts, active sessions, last disconnect time, last connect-error reason/time) to `config/relay-tunnel.state.json` via atomic single-writer writes (unique `*.tmp` + `LOCK_EX` + `0600` + `rename()`, mirroring `HubClient::storeEnrollment()`). This is an internal groundwork mechanism consumed by the upcoming Network Health work (S40); the relay fork writes it on connect/disconnect/reconnect and every connect-failure branch. - **DLNA inbound IP allowlist** (updates.md #35 / S50). The DLNA ContentDirectory (CDS) browse routes — which have NO authentication once `dlna.cds_enabled` is on — are now gated by a new `Server\Http\Middleware\DlnaAllowlistMiddleware`, wrapped around `Application::loadCdsRoutes()`'s route group exactly as `SignedUrlMiddleware` wraps the streaming group. Two new `config/dlna.php` keys drive it: `allowed_cidrs` (array of CIDRs, default empty) and `restrict_to_lan` (bool, default **true**). The client address is resolved via the spoof-resistant `Request::getTrustedClientIp()` (the same accessor the rate limiters use), and an IPv4-mapped IPv6 peer is collapsed to its IPv4 form before matching. CIDR parsing/matching **reuses** `Common\Net\SsrfGuard` rather than re-rolling it (its `filterCidrs()`, `ipMatchesAnyCidr()` and `embeddedIpv4()` helpers are now public). Policy, and the whole point of the change: an **empty allowlist is never "allow all"** — with the defaults, only loopback + RFC1918/ULA/link-local may reach the CDS routes and everything else gets a 403; with `restrict_to_lan=false` the allowlist becomes the sole gate (an empty list then denies everyone). The matching shared-settings schema keys (`dlna.allowed_cidrs`, `dlna.restrict_to_lan`) are added in `detain/phlix-shared` and surface once that package is released and re-vendored. This step wires only the existing CDS routes; the new DLNA stream route (S52) is out of scope. - **Nav entries hide without a matching library type** (updates.md / S25, Half B). The web-ui now consumes `@phlix/ui` **v0.98.22**, which added the optional `MenuItem.requiresLibraryType` field plus a fail-closed nav filter (Half A). The server SPA menu in `web-ui/src/main.ts` tags its media-type entries with the matching **singular** server library `type` ENUM value — `Music → 'music'`, `Books → 'book'`, `Audiobooks → 'audiobook'`, `Photos → 'photo'` — so each link appears only when a library of that type exists (and stays hidden while the library list is still loading). Browse / For You / Explore / SyncPlay / Search / Settings / Admin are untouched. The committed `public/assets/app/` bundle was rebuilt so the deployed nav reflects the change; no backend/API change. - **Per-library TMDB auto-collections toggle (backend)** (updates.md / S33). TMDB box-set auto-collection generation is now switchable **per library** instead of running unconditionally on every scan. The flag lives in the existing `libraries.options` JSON column as `{"autoCollections": {"enabled": bool}}` (no new migration/column) and round-trips through the existing library create/update endpoint: `LibraryController` gained an `applyAutoCollections()` handler that accepts the flag at the body top level OR nested in `options` (as a bare bool or a `{enabled: bool}` map), normalises it to the canonical `{enabled: bool}` shape, and — on update — MERGES it into the existing options blob so editing it preserves `metadata_priority`/`image_types`/`series_per_directory` (and vice versa). `LibraryRow::autoCollectionsEnabled()` reads it, **defaulting to `true` when the flag is absent** so existing libraries keep today's behaviour; only an explicit stored `false` disables generation. `LibraryRow::toArray()` surfaces the effective value under a top-level `auto_collections` block. `MediaScanner::scan()` takes a new optional `bool $autoCollectionsEnabled = true` (latched per-scan) and gates its per-item `CollectionService::syncCollectionForMovie()` block on it; `LibraryManager::scanLibrary()` passes the library's resolved flag. The library-edit UI checkbox is a separate deferred UI-lane task. - **`GET /api/v1/users/me/next-up` — per-user "Next Up" rail** (updates.md #43 / S36). The sibling to Continue Watching: where CW lists in-progress items you can resume, Next Up returns, for **each series the active profile has STARTED, the single next unwatched episode to play**. New pure `Phlix\Media\Library\NextUpSelector` (DB-free ordering + next-episode selection — a server-side port of the SPA's `episode-order.ts`), new `WatchHistory::getNextUp(string $profileId, int $limit = 20)` (resolves `profileId → userId`, Query A = the started series most-recently-touched first, Query B = every episode of a series with the user's latest per-episode `playback_state`), and the route + handler `WebPortalRouter::getNextUp()` registered beside `continue-watching` in the same `AuthMiddleware` group. Optional `?limit=` (default 20, clamped 1-50); the candidate-series fan-out is bounded at `max(limit × NEXT_UP_SERIES_SCAN_MULTIPLIER(3), NEXT_UP_SERIES_SCAN_FLOOR(50))` most-recently-touched series (LIMIT inlined as a validated int — bound LIMIT raises 1064 under emulated prepares in this repo). **Selection:** an in-progress episode (playing/paused within 0–95% of duration) resumes that episode; a finished episode (`stopped` at position 0, or watched ≥95% of duration) advances to the next numbered episode, rolling into the next numbered season; a series with all episodes watched yields no entry; only numbered seasons are walked (Specials/season-less excluded). **[BINDING design]** the watched/in-progress signal is `playback_state` ONLY — the `watch_history` table and `user_item_data.watched` flag are deliberately NOT consulted (they are write-orphaned / a manual account-level toggle). Response is a bare `{items:[...]}` of `MediaItemShaper::shape()`-shaped episode rows (series poster resolved, artwork re-signed) with `position_ticks`/`duration_ticks` = 0 (a fresh pick) plus added `series_id`/`series_name`; each carries `media_item_id`, the key the active-profile parental rating gate post-filters on (over-cap episodes dropped for a gated profile; owner unfiltered). `401` unauthenticated, `503` when watch-history is unwired, `{items:[]}` when the user has no active profile. The season-less (flat, no season row) series edge is invisible to the rail by design (the numbered-season-only binding). Home-rail UI is a separate step (S37). - **`GET /api/v1/media/most-watched` — public "Most Watched" trending rail** (updates.md #31 / S31). Exposes the GLOBAL, all-time, cross-user most-watched aggregate (the same list the admin Top Media report reads via `StatsCollector::getTopMedia()`) to signed-in users as a home-rail. This is a server-wide trending rail (most-watched across the WHOLE server), NOT a per-user history — a deliberate product decision: everyone sees the same "popular on this server" list. New `MostWatchedController::mostWatched()` takes an optional `?limit=` (default 20, clamped to the `PageLimit::MAX` ceiling of 100 via `Request::queryPageSize()`), hydrates the top IDs with a single `ItemRepository::findByIds()` batch (order-preserving; silently drops since-deleted rows), and returns the sibling `{items, total, limit, offset}` envelope with `MediaItemShaper::shape()`-shaped rows (poster/artwork signed URLs re-minted at response time). Registered on the shared `Application` router as a STATIC route (matched before `WebPortalRouter`'s parametric `/api/v1/media/{id}`, so no shadowing) and gated by `AuthMiddleware` to match the audience of `GET /api/v1/media`. The admin-only `getTopMedia()` path (`/api/v1/admin/stats/top-media`) is unchanged. - **`POST /api/v1/sessions/{id}/complete` — explicit playback finish signal** (updates.md #30 / S30). Progress ticks (`reportProgress`) only ever leave a `playback_state` row in `playing`/`paused`, so without an explicit finish signal a finished title lingered in Continue Watching and its watch-time stats never finalized (Top Users watch time showed dashes). The new `SessionController::completePlayback()` gives `PlaybackController::markAsWatched()` / `clearProgress()` their first live caller. Body: `media_item_id: string` (required; `400` if missing/empty) and optional `reached_end: bool` (default `true`). `reached_end:true` → `markAsWatched()` (row → `stopped`, `position_ticks=0`, item leaves Continue Watching, stats recorded as completed — finalizes `duration_seconds` + dispatches `PlaybackStopped` for webhooks/Last.fm/Most Watched); `false` → `clearProgress()` (deletes the row, recorded as not completed). Response `200 {message, reached_end}`; `404` unknown session, `403` wrong owner. Auth mirrors the sibling `/progress` route (session owner), registered next to `/progress` on the shared `Application` router (served by the Workerman `HttpHandler` daemon). The web SPA full player (`Player.vue`) and the persistent mini-player POST this on the media `ended` event. **Known limitation / follow-up:** native clients (Roku / mobile / Tizen / Windows) do NOT send this signal yet, so titles finished on those clients still linger in Continue Watching and do not finalize watch-time until each is updated to call `/complete`. - **`plugins.catalog.channel` — plugin-catalog release channel (`stable` / `dev`).** Lets the OFFICIAL first-party plugin catalog (`detain/phlix-plugins`) track something other than a hard-pinned release tag, without losing the safe default. Adds the `plugins.catalog.channel` setting (`stable` **default** | `dev`) and a `GET/PUT /api/v1/admin/plugins/catalog/channel` admin endpoint (admin-gated like every sibling plugin route; `PUT` validates the value against `stable|dev` → `400 plugin.catalog.channel.invalid`, audits `catalog.channel`). The same `{ channel, options: [{ value, label, description, advanced }] }` shape is also embedded under `channel` in the `GET /plugins/catalog` response. Channel resolution has strict precedence **env > setting > default**: 1. the `PHLIX_PLUGINS_CATALOG_REF` env override — unchanged, still highest; 2. `plugins.catalog.channel` — `dev` resolves the official catalog to its moving `master` branch (`CatalogSourceResolver::DEV_REF`); 3. `CatalogSourceResolver::OFFICIAL_PINNED_REF` — the audited pinned tag, used for `stable` / default. Any unrecognised or empty value fails **safe to `stable`** in three independent layers (`refForChannel()`, `channel()`, `setChannel()`). `dev` is labelled **opt-in / advanced** server-side via `PluginCatalogService::channelInfo()` (the `dev` option carries `advanced: true` and a description explaining it tracks the moving `master` branch); the admin Plugins page renders that metadata verbatim. The channel only affects the OFFICIAL catalog; operator-added catalogs still resolve at `HEAD`, and **install-time integrity verification is unchanged**: per-entry `ref` + `artifactSha256` still gate every actual install on BOTH channels (`PluginCatalogService::pinFor()` / `PluginLoader::install()` untouched). ⚠ **Corrected (S217).** An earlier draft of this entry — and of the docblocks at `CatalogSourceResolver`, `PluginCatalogService::channel()`/`channelInfo()` and `PluginCatalogController::channel()` — claimed the channel *"only widens discovery"* and that **"`dev` never moves the trust boundary"**. That overstates the guarantee and is now withdrawn from all four sites. The digest check does survive, but `ref` and `artifactSha256` are **self-asserted by the catalog document**: verification proves only that the artifact matches what *that document* claimed. On `stable` the document is read from `OFFICIAL_PINNED_REF`, an immutable release tag; on `dev` it is read from `DEV_REF`, a moving branch — so anyone who can push that branch can pin their own commit **and** its matching digest, and the install verifies. `dev` widens discovery **and** swaps an immutable trust anchor for a mutable one. No behaviour changed; only the claim did. The `channelInfo()` `dev` description the admin SPA renders verbatim was reworded to say so. Passing `null` for the new `CatalogSourceResolver::normalize()` `$officialRef` argument reproduces the historic env-or-pinned behaviour byte-for-byte, so existing callers are unaffected. GitHub-Releases-API "always latest" (updates.md #27 option b) is intentionally deferred. Class (a) LIVE, `restart: false`. **S217: `plugins.catalog.channel` now has a DECLARED default.** `config/plugins.php` gains `'channel' => 'stable'`. Previously the key existed only as a code fallback (`PluginCatalogService::channel()` returns `stable` for any non-`dev` value), so `SettingsRepository::hasDefault('plugins.catalog.channel')` was **false** and `getDefault()` returned `null`. Behaviour is unchanged today — the code fallback and the config default agree — but the key is now resolvable, which is the precondition for publishing it to `detain/phlix-shared`'s `server-settings.schema.json`. Publishing it there *without* this default would have reddened `tests/Unit/Admin/SettingsDefaultResolvabilityTest`, whose whole purpose is to catch a schema key that renders in the admin UI, accepts a PUT and reports `null` forever. `tests/Unit/Plugins/Catalog/CatalogChannelConfigDefaultTest.php` pins the default against the real `config/` directory. The shared-schema half is an **upstream** phlix-shared change and is deliberately NOT made here (`vendor/` is hand-patched). - **`metadata.overwrite_existing` — respect hand-corrected metadata on rescan.** A metadata (re)match unconditionally did `array_merge($existing, $resolved)` at every persist site, so freshly-resolved fields always won and there was no way to stop a forced rescan (or an interactive re-apply) from clobbering a hand-corrected item. Adds the `metadata.overwrite_existing` boolean setting (phlix-shared v0.41.0), `default: true` so behaviour at the default is byte-for-byte identical to before. Wired through a new `MetadataOverwritePolicy` (a direct mirror of `ArtworkDownloadPolicy`: optional `SettingsRepository`, live `getEffective()` read, safe-degrades to overwrite-on) that gates ONE decision point, `LibraryMetadataMatcher::shouldSkipOverwrite()`. That helper is consulted at the three (re)resolve entry points — `matchItem()`, `matchSeries()` and `applyMatchResolved()` — which between them dominate every `array_merge($existing, $resolved)` metadata-overwrite site in the class (movie item, series root, the interactive AND batch season/episode enrichment sites, and all four interactive-apply branches), so a subset cannot silently drift. Class (a) LIVE, `restart: false`; the default lives in `config/metadata.php` (not composed into `config/server.php`, read via `SettingsRepository`). When an admin turns it OFF, an item that has ALREADY been resolved (`metadata_refreshed_at` present) is skipped WHOLESALE on a forced rescan and on interactive apply — not re-resolved, not re-merged — mirroring the existing manual-override short-circuit in `matchItem()`. There is no per-field provenance in the pipeline, so "don't overwrite" can only be a whole-item skip. Items never yet resolved are still enriched. Wired in DI as a named ctor param (`MediaServicesProvider`) so PHP-DI does not skip it; `MetadataOverwritePolicy` registered as a factory over the optional settings store. New tests assert the CONSEQUENCE across the movie / series-subtree / interactive paths: at the default an already-resolved item is still overwritten (rule 7); with the switch off it is skipped wholesale, while a never-resolved item is still enriched. - **`POST /api/v1/admin/plugins/{name}/test` is now actually routed.** `PluginAdminController::testCredentials()` was fully implemented and reachable by nobody — `AdminRoutes::register()` registered every other plugin route and stopped short of this one, so every call 404'd. That is why a "Test credentials" button was deliberately withheld from `phlix-ui`. The route is now registered alongside its siblings, inside the same `/api/v1/admin` group and therefore behind the same `AdminMiddleware` gate. Registered **once**, in the registrar both entry points call (`Application.php:771` daemon + `public/index.php:213` web portal), so there is no dual-entry-point drift. New tests assert the **consequence**, not the route table: an anonymous POST gets 401 (not 404 — proving the route exists *and* is inside the admin group), a known non-admin gets 403 *and the plugin's `testCredentials()` is never invoked with the submitted secret*, and an admin POST reaches the controller, invokes the plugin with the submitted settings, and gets its verdict back in the `{success, message}` envelope. Mutation-verified: deleting the registration turns all ten new tests red with 404, and moving the route outside the admin group turns both gate tests red. - **The OAuth2/OIDC `redirect_uri` is now always ABSOLUTE, and is derived only from a `Host` that matches `PHLIX_DOMAIN`** (updates.md #54 / S48). Both browser flows used to send a path-only `redirect_uri` (`/auth/oidc/callback`), which providers reject with `redirect_uri_mismatch` — GitHub and any spec-strict OIDC IdP match the value's scheme, host **and port** against a registered absolute URI. The callback URL is now resolved in two steps: the provider's own `redirect_uri` setting when it is a valid absolute http(s) URL, else `<scheme>://<Host><callback path>` **only** when the request's `Host` — port included — is the configured `PHLIX_DOMAIN`. A `Host` is client-supplied, so deriving from an unvouched-for one could have handed a wildcard-registered IdP an attacker's `redirect_uri`. **Upgrade note — this fails CLOSED.** If neither `PHLIX_DOMAIN` nor an absolute per-provider `redirect_uri` is configured, `/auth/github/authorize` and `/auth/oidc/authorize` answer **`503 callback_url_not_configured`** — issuing no state row and no cookie — instead of sending a value the provider would reject. Set **`PHLIX_DOMAIN`** to this server's public `host[:port]` (`scripts/install.sh --domain <domain>` writes it, and the systemd unit reads it from the env file), or set the provider's `redirect_uri` setting to the absolute URL registered with the provider. Reaching authorize over anything else — a LAN IP, `localhost`, the hub relay hostname, or a different port on the same hostname — now `503`s **by design**. The response body is deliberately generic because the route is unauthenticated; the AUTH-channel log line names which condition fired, the presented `Host`, the configured domain and both remedies. `error: callback_url_not_configured` is the stable machine-readable code. **The port is part of the compared origin**, with the scheme's DEFAULT port normalised away on both sides: `PHLIX_DOMAIN=media.example.com` accepts `Host: media.example.com` **and** `Host: media.example.com:443` under https (a reverse proxy really does forward the latter), while `PHLIX_DOMAIN=media.example.com:8443` accepts only that exact `host:port`. Mixed pairs are distinct origins and refused (`https://host:80`, `http://host:443`). **One behavioural delta to know:** phlix-server also binds `0.0.0.0:8096`, so a client that BYPASSES the reverse proxy presents `Host: <domain>:8096` and now gets the `503` instead of a derived `https://<domain>:8096/auth/…/callback`. Nothing completable is lost — the callback registered with the OAuth App / IdP is the proxied form, so the provider would have answered `redirect_uri_mismatch` for the ported value anyway; the failure moves earlier and names the fix. Set the provider's absolute `redirect_uri` if you genuinely serve auth on that port. In-flight upgrade window: a state row minted under the older bare-host rule whose `callback_url` carries a port `PHLIX_DOMAIN` does not name is no longer replayable, so that one login attempt ends in the `503` and the retry (through the proxy) succeeds. Bounded by the 600 s state TTL, and only for a login started on a non-registered origin. - **DLNA browse now reports the real container MIME for ~20 more formats** (updates.md #44 / S52). The extension→MIME table that produced the DIDL `<res protocolInfo>` value lived in three places (`LibraryBridge`, `ContentDirectory`, and it was about to be copied a fourth time for the stream route), which is how the MIME a renderer is told to expect and the `Content-Type` the bytes arrive with could silently diverge — a renderer that sees the two disagree refuses the item. There is now one table, `Phlix\Dlna\DlnaMimeTypes`, and it is a **superset** of the old one, so the advertised `mime_type` changes for `mov`, `wmv`, `flv`, `mpg`, `mpeg`, `m2v`, `ts`, `m2ts`, `mts`, `3gp`, `ogv`, `m4a`, `m4b`, `opus`, `wma`, `aiff`, `aif`, `oga`, `bmp`, `webp`, `tif`, `tiff`. Examples: a `.mov` typed `movie` was advertised `video/mp4` and is now `video/quicktime`; a `.m4a` typed `music` was `audio/mpeg` and is now `audio/mp4`. The resolution ORDER (explicit `mime_type` → extension → coarse `type` fallback) is unchanged. The old values were simply wrong, so this is an improvement, but it is a visible change in the Browse response. - **Bumped `detain/phlix-shared` to `^0.27.0`; the server-settings schema went 42 keys → 41.** `transcoding.include_software_fallback` was deleted upstream as a **consumerless** key — the same defect shape as `hwaccel.probe_timeout` in v0.26.0, and the reason `SettingsDefaultResolvabilityTest` did not catch either: **resolvable ≠ consumed.** The key resolved cleanly to `config/transcoding.php:44` and `HwAccelConfig::get()` copied it into the merged hwaccel array, but nothing read the merged value. That array reaches exactly two consumers, and neither reads this key: `FfmpegRunner::setConfig()` (`Application.php:2971`, `TranscodeServicesProvider.php:149`), which reads precisely `tone_mapping_mode` / `prefer_hdr_output` / `preferred_accelerator` / `enabled` / `prefer_hardware` and whose `getConfig()` accessor has no caller in `src/`; and `HwaccelRegistry`, whose software-fallback branch (`HwaccelRegistry.php:160,206`) reads the **separate** `hwaccel.fallback_to_software` key out of `config/hwaccel_base.php`. So the toggle was inert in both directions — turning it off never disabled software fallback, turning it on never enabled anything. Deleted rather than wired (plan §4 rule 10) because the working equivalent already exists: `hwaccel.fallback_to_software` is genuinely consumed and is the key to expose if a software-fallback toggle is wanted. The `config/transcoding.php` default and the `HwAccelConfig::get()` merge line are **retained**, now carrying a "NOT AN ADMIN SETTING" comment, so the merged array shape is unchanged for any caller reading it defensively — mirroring exactly what v0.26.0 did for `probe_timeout`. The `assertCount` lock-in and its dotted-key → internal-type map in `AdminSettingsControllerTest` were updated to 41 and remain **deliberately hand-written**: deriving them from the vendored schema would make the assertion tautological. Mutation-verified by re-adding the key to the hand-written list and confirming the test goes red against the 41-key vendored schema. - **Bumped `detain/phlix-shared` to `^0.25.0`; the server-settings schema went 53 keys → 40 → 43.** The v0.24.0 re-vendor deleted every key that resolved to no config path and had no runtime consumer — the `trakt.*` trio, the `subsystem.*`, `database.*` and `hls.*` families, the duplicated `transcoding.*` tunables (the surviving equivalents live under `ffmpeg.*`, `server.hls.*` and `process.*`), and the unimplemented `metadata.preferred_*` / `metadata.fanart_api_key` / `auth.*` extras. Those keys rendered in the admin Settings page, accepted a `PUT`, and reported `null` forever; they are now simply absent. The controller derives its allow-list from the schema, so no server-side key list needed editing — but the `assertCount(53)` lock-in test and its explicit dotted-key → internal-type map were updated to the real count, deliberately kept hand-written so an unreviewed schema addition still fails loudly rather than being auto-derived into a no-op. **Correction — the `trakt.*` trio should not have been in that sweep, and is restored in v0.25.0 (see *Fixed* below).** Unlike the other deletions, those three keys always had a real runtime consumer; only their *config default* was unreachable. Deleting them cost a genuine admin capability, and the earlier suggestion here to re-add them as `scrobblers.trakt.*` was wrong: that prefix does resolve, but renaming would orphan `trakt.*` rows already persisted in `server_settings` on live installs. - **`transcoding.preferred_accelerator`'s "auto-detect" sentinel is now the empty string, not `null`.** `config/transcoding.php` defaulted to `getenv('PREFERRED_ACCELERATOR') ?: null` against a schema whose auto-detect enum member is `""`, so `GET /api/v1/admin/settings` returned `null`, the SPA's `<select>` matched no option and rendered blank, and saving that blank value back failed the new enum enforcement. The config default is now `''`. The consumer in `FfmpegRunner` was already correct (`is_string($x) && $x !== ''`), so no transcoding behaviour changes. The schema's enum was also corrected upstream for accuracy: `nvenc` was dropped (it is an FFmpeg *encoder*, not an hwaccel name) and `v4l2` became `v4l2m2m`; `d3d11va` and `dxva2` were added. A new test asserts every `enum`-constrained key's config default is one of its declared members, which covers this whole class of drift rather than just the one key. - **Legacy Smarty page UI retired — the `/app` Vue SPA is now the ONLY web UI.** All legacy server-rendered (Smarty) portal page routes now issue a **302 redirect** to their `/app` equivalent (dispatched in `public/index.php`), and the SPA shell is served for `/app` + `/app/*`. High-level redirect map: `/` → `/app`; `/login` → `/app/login`; `/register` → `/app/signup`; `/library` → `/app`; `/library/{id}` → `/app/library/{id}`; `/media/{id}` → `/app/media/{id}`; `/player/{id}` → `/app/player/{id}`; `/search` → `/app/search`; `/settings` → `/app/settings`; `/admin` → `/app/admin/dashboard`; `/admin/plugins` → `/app/admin/plugins`; the **Music** pages (`/music`, `/music/album/*`, `/music/artists`, `/music/artist/*`, `/music/tracks`, `/music/player`) → `/app/music/*`; **Books** (`/books`, `/books/{id}`, `/books/{id}/read`) → `/app/books/*`; **Audiobooks** (`/audiobooks`, `/audiobooks/{id}`, `/audiobooks/{id}/read`) → `/app/audiobooks/*`; and **Photos** (`/photo/albums`, `/photo/album/{id}`, `/photo/photo/{id}`, `/photo/slideshow`, preserving the `library_id` query string) → `/app/photo/*`. The redirects stay auth-gated where the legacy page was, so an unauthenticated browser is bounced to `/app/login` first. - **Test-coverage hardening.** Added missing behavioral tests across SV-0.6 (TMDB collections UUID handling), SV-0.7 (marker/intro-detection worker supervision), SV-1.8 (CSRF Origin exact-match), SV-1.9 (ENOSPC guard), SV-4.8 (Router static-map fast path + DI string-handler resolution), and SV-4.12 (stale-job reaper glob), plus the SV-3.3 and SV-4.15 features above — closing "green-but-untested" gaps found during re-audit. No behavior change beyond the fixes listed above. - **Plugin catalog pinned to v2.1.8.** Bumped `OFFICIAL_PINNED_REF` (the tag of `detain/phlix-plugins` the admin **Plugins** catalog resolves against) to **v2.1.8**, shipping the trakt/musicbrainz/anilist PHP-8.5 `curl_close()` fixes to the catalog listing. - **The mDNS multicast join in `MdnsSocket` was a silent no-op; it now joins 224.0.0.251 with the `MCAST_JOIN_GROUP` spelling and logs failure (S296).** The join used the BSD `IP_ADD_MEMBERSHIP` spelling, a constant PHP 8.3 does not define (measured `defined()` === false), so the call fell back to raw option 12 with `inet_pton($group)` (a 4-byte binary string) as optval; `socket_set_option()` returned `TRUE` and joined NOTHING — zero multicast datagrams reached mDNS/Bonjour discovery while every caller believed the join succeeded (a `TRUE` return is exactly why it went unnoticed). The join now uses `MCAST_JOIN_GROUP` + array optval (`['group' => '224.0.0.251', 'interface' => 0]`) — the exact spelling S51 shipped and measured in `Dlna\SsdpAdvertiser` — extracted into `joinMulticastGroup()`, with failure now logged (fail-loud: this hazard class is silent failure). The Swoole coroutine-runtime socket variance is guarded: the parameter is deliberately untyped because `SWOOLE_HOOK_SOCKETS` hands `socket_create()` back a `Swoole\Coroutine\Socket`, not a `\Socket`, and the call is wrapped in `try/catch(\Throwable)` so the daemon can never TypeError/fatal on this path. Proven by `tests/Unit/Discovery/Mdns/MdnsMulticastJoinTest.php`, a three-arm experiment on real sockets: (A) no join receives nothing (control); (B) the pre-fix spelling replayed verbatim returns `TRUE` and receives NOTHING (failing control, measured on PHP 8.3.6); (C) the production join method receives the datagram; both mutations (join line deleted, spelling regressed) redden the test. KNOWN LIMIT: this box/dev-VM loops IPv4 multicast back only on `lo`, so `interface => 0` kernel route selection is NOT validated — the option spelling is what is proven, not the interface pick. No migration (next-free server migration stays 103). ### Removed - **`UserProfileManager::setActiveThemeId()` deleted — the unvalidated bypass door around the live theme law (2026-10-14).** The method wrote `user_profiles.active_theme_id` with ZERO validation — the exact defect class the same-day `UserRepository::updateSettings()` fix retired — and an estate-wide grep (this repo's `src/`, `tests/`, `bin/`, `scripts/`, `start.php`, `docs/`, plus every `phlix-*` sibling repo's tracked tree) found zero callers. Its only surviving mention was the historical S85-era CHANGELOG release note, which is a record, not an adoption promise — so deletion beats deprecation. `UserRepository::updateSettings()` is now the estate's SINGLE writer of the column; `getActiveThemeId()`'s stale `@see setActiveThemeId()` line re-points there. No routes, no runtime behavior change (dead code), no census movement from the deletion itself. - **Untracked the repo-root `PROGRESS.md` lane journal; sandbox journals stay out of git (`S487`).** The file was a lane's scratch progress log that entered the tracked tree with the two W71 merges and belongs only under `.sandboxes/<lane>/`, outside every git working tree. Both authored versions remain recoverable from history; the root `.gitignore` now carries a `PROGRESS.md` line so a future `git add -A` can never re-track a repo-root journal. No routes, no behavior change, no census change. - **Unserved CGI front controller deleted (`S171`).** `public/index.php` (376 lines) was executed by nothing on any published deployment artifact — re-measured at the step's tip: the Dockerfiles run php-cli only under supervisord's `start.php` program (the fpm/nginx binaries are removed from the base image), `systemd/phlix-server.service` ExecStarts `start.php`, the compose files and examples reference nothing under `public/` as a program, `scripts/install.sh` actively REWRITES legacy `public/index.php start` units onto `start.php`, and the docker boot gate ASSERTS no index.php/php-fpm/nginx process runs in the image. The file nevertheless kept compiling, kept being tokenized by the census, and kept reading like a supported path — enough that S99's audit had to qualify its own finding as "Workerman path only". Deleted with a permanent Unit guard (`tests/Unit/Docker/PublicFrontControllerRemovalGuardTest.php`) pinning the absence plus that the artifacts it pins — supervisord, the Dockerfiles, the compose files, the shipped reverse-proxy configs, the helm charts, the systemd unit — front nothing; mutation-verified (recreating the file reddens it). The S163 incident block in `docker/supervisord.conf` and the `docker/README.md` narrative are updated to past tense; the dual-entry-point pin in `ThemeEndpointsReachabilityTest` is reworked to the sole authoritative entry point. S116's `du -sb` request-path residual closes with the file — the daemon records snapshots via its timer instead. "Unserved" is measured on THIS deployment; the guard claims no impossibility on a third-party fpm stack. No routes, no migrations, no behavior change to the daemon. - **Whole-tree unused-import reflow (`S187`).** 150 dead `use` statements removed across 100 files (`src/` 58, `tests/` 90, `scripts/` 2) — content-neutral by construction: the phlix-contracts server route manifest regenerates byte-identical (401 tuples) before and after, and PHPStan (`src` level 9 + `tests` level 2) and both Psalm configs report identical issue sets. A permanent detector now guards the invariant: `tests/Unit/Support/UnusedImportGuardTest.php` tokenises every first-party PHP file and fails on any import unreferenced in its own file, counting docblock-only mentions as used (PHPStan/Psalm resolve FQCNs through them). Planted-import mutation proofs for both `src/`-style and `tests/`-style fixtures ship inside the same guard; an empty scan tree fails fast instead of passing. No new dependencies, no baselines, no ignore lists (`composer.lock` byte-unchanged). - **The pre-S84 `ThemeRegistry` theming island (S227).** `ThemeRegistry`, `Theme`, `ThemePluginInterface`, `config/themes.php` and their unit test had zero implementors and zero resolvers estate-wide — only `ThemingServicesProvider` still constructed the registry, and nobody resolved it. With S84–S86 complete they were a second, fake theming subsystem beside the real token-map one, carrying the name a reader reaches for first. The container binding is gone, two historical `{@see}` pointers were reworded, and `tests/Unit/Theming/ThemingIslandRemovedTest.php` now fails if any of it returns. `ThemeSourceRegistry` and the real subsystem are untouched. - **`dash_url` dropped from the transcode/status response payloads** (updates.md #11 / S11). `POST /api/v1/media/{id}/transcode` and `GET /api/v1/transcode/{jobId}/status` (via `TranscodeController::start()` / `::status()`), plus both `TranscodeManager::ensureHlsJob()` return arrays (reused-job and fresh-job), previously advertised a `dash_url` → `/dash/{job}/manifest.mpd`. Real DASH is **not** produced by the on-demand pipeline: `ensureHlsJob()` emits a multi-variant HLS `.ts` ladder and never invokes the CMAF DASH muxer (`FfmpegRunner::buildCmafCommand()` / `startCmafTranscode()` exist but are not wired into the on-demand flow), so that manifest never exists on disk and every request to the advertised URL 404'd. The field is now removed — and the `ensureHlsJob()` `@return` docblock narrowed to match — so clients are no longer handed an unresolvable URL. The DASH library (`DashStreamer`, `StreamManager`, and the CMAF muxer path) is left untouched, reserved for real DASH support tracked as **S56-S60** (updates.md #57). Repo-wide grep confirmed no `dash_url` reader remained in `src/`, `tests/`, phlix-ui, or web-ui — no known client depended on the key's presence; tests dropped their `dash_url` assertions in lockstep and added absence guards. - **Dead `Router::dashStreaming()` route removed** (updates.md #28 / S28). The helper wired `/dash/*` to `DashController::getMasterManifest`/`getAdaptationSetManifest`/`getSegment` — methods that do not exist — and had ZERO callers (grep-clean across `src/`, `tests/`, `public/`). It is deleted. The real DASH routes registered in `Application::loadStreamingRoutes()` (`GET /dash/{job}/manifest` → `getManifest`, `GET /dash/{job}/{file}` → `serveFile`) and the `DashStreamer`/`DashController` library are untouched, reserved for real DASH support tracked as **S56-S60** (updates.md #57). An incidental genuinely-dead `$scpdUrls` property + `setupScpdUrls()` in `DlnaServer` (surfaced by the SOAP-parser rewrite; `getScpdXml()` reads the services directly and never the map) was removed in the same step. A `DashRouteRemovalTest` guards that the dead route and phantom controller methods stay gone while the real `getManifest`/`serveFile` remain. - **Deleted the migrated Smarty page-rendering stack.** `PageRenderer`, the media/admin page controllers, all `public/templates/**/*.tpl` page templates (auth, home, library, player, music, books, audiobooks, photo, search, settings, admin dashboard/plugins/music/lastfm, layouts, partials), and the legacy per-page JS/CSS were removed now that the `/app` SPA supersedes them. **Retained on the server (still live):** `smarty/smarty` (composer) + `src/Admin/NewsletterGenerator.php` + `public/templates/emails/newsletter.tpl` — the **newsletter email** still renders via Smarty; `ThemeMiddleware`; `WebPortalRouter` (serves the portal JSON under `/api/v1/*`); `SharedUiController` + `ViteAssets` (serve the SPA shell + built bundle); and the legacy **binary** API routes (artwork, book cover/download, photo thumbnail/full, media stream). This supersedes the earlier "Smarty retirement pending owner verification" note in this changelog. - **§6 dead-code removals (user-approved).** Removed confirmed-dead code paths with zero runtime callers (verified by repo-wide `grep`/`git grep` before deletion): - *Run A:* `FfmpegRunner::killSegmentProcess()`; the `HwaccelCommandBuilder` class and `HwaccelProfileFactory::createCommandBuilder()`; the never-wired webhook async island `WebhookDispatcher::dispatchAsync()` + `sendToWebhookWithBackoff()`; the dead duplicate `src/Server/Http/Controllers/ArtworkController.php` (the live artwork path is `HttpHandler::serveArtwork`); and unused `RequestContext` members (`clearUserId()`, `hasProfileId()`, `clearProfileId()`). The live Hwaccel registry/profile/probe system and `WebhookDispatcher`'s live `dispatch()`/`sendToWebhook()` path are untouched. - *Run B:* the dead server gapless/crossfade subsystem — `FfmpegRunner::buildGaplessSegmentCommand()`, `GaplessTranscoder`, `CrossfadeGenerator`, `GaplessPlayer`, and the now-orphaned members of `GaplessPlaybackManager` (`getPlayer()`, `getCrossfadeGenerator()`, `buildCrossfadeCommand()`, `isCrossfadeEnabled()`, `isGaplessEnabled()`, `clearPlayer()`, and later the caller-less `clearCache()`). Gapless/crossfade playback is implemented **100% client-side** in `@phlix/ui` (`useMusicPlayer.ts`), which superseded the reserved server path. `GaplessPlaybackManager` itself is kept — its live `getPreferences()` surface is still used by `MediaItemController`. ### Security - **OAuth callback URLs are no longer fabricated from a spoofable Host header (SRV-M15, review server__fedev).** `LastfmController::apiCallbackUrl()` built the Last.fm `cb` parameter from the raw request `Host` plus an unvalidated `X-Forwarded-Proto`, with a relative-path fallback when no host header arrived at all; `PluginAdminController::buildOAuthRedirectUrl()` did the same (with a `localhost` fallback) and handed the result to the admin panel as the URL to register at the provider. A spoofed host therefore either poisoned the OAuth handshake with an attacker-chosen callback origin or instructed the operator to register one. Both sites now route through the estate `Phlix\Plugins\OAuth2\CallbackUrl` guard used by the GitHub leg: an explicitly configured absolute `callback_url` wins (and for Last.fm only when it actually points at the API callback path — legacy `/admin/lastfm/callback` values never ride the new flow), otherwise the host is honored only when it matches the operator-allowlisted `PHLIX_DOMAIN`; no match means no URL — Last.fm redirects to `/app/admin/services?lastfm=not_configured` **before** any OAuth state is minted, and the plugin detail endpoint serves `redirect_url: null` instead of a forged absolute. No new wire error codes were added. Pins: three new `LastfmControllerTest` refusals (no-allowlist fail-closed, foreign-host refusal, configured-API-path precedence) plus a `PHLIX_DOMAIN=phlix.test` setUp/tearDown hermeticity law on the suite — measured RED at the pre-cure tree (12 tests, 3 failures, the wire diff literally showed `cb=https%3A%2F%2Fevil.test%2F...`); two new `PluginAdminControllerTest` detail-endpoint tests (the untrusted-host refusal measured RED verbatim at parent, the allowlisted-host pin is behavior-preserving green at parent — disclosed); the existing configured-redirect pin keeps its assertions byte-identical under the new derivation law. - **`must_change_password` is now an account hold — passkey and provider sign-in can no longer mint sessions around it (SRV-M18, review server__pm).** The flag was consulted only on the two password legs (`AuthManager::login()`, `AuthManager::refreshToken()`), while `AuthManager::loginWithProvider()` completed its whole success bookkeeping (`updateLastLogin`, a success `logLogin` audit, activity, `UserLoggedIn` dispatch) and every external minter — `WebAuthnController::finishAuthentication` (WebAuthn ships **enabled** by default), quick-connect approval, the profile-switch endpoint — called the public `AuthManager::buildAuthResponse()` seam blind. An admin who force-changed a password to contain a compromise therefore left the account fully sign-in-able through a registered passkey. The wall is now two-tiered: `loginWithProvider()` refuses the held account with the pre-existing typed `PasswordChangeRequiredException` + a `password_change_required` audit **before** any success bookkeeping, and `buildAuthResponse()` — the single seam every mint converges on — refuses as its first act, so the remaining external minters (and any future ones) are covered at the chokepoint. The two password legs keep their own earlier typed checks; their shipped envelopes are byte-identical (the sibling latch suites pass UNMODIFIED). Per-leg wire outcome: the provider refusal answers the `AuthController::ldapLogin()` generic `RuntimeException` arm (503, fail closed, no token) and the seam refusals surface as the same unmapped exception the password leg already serves — no envelope was changed and no wire error code was added; the distinct `403`-with-code mapping remains the adjacent SRV-M17 finding. The README and the admin-endpoint/exception/ repository docblocks that disclosed the bypass as shipped behavior were reworded to the new law. Four pins in `AuthManagerLatchGateTest` (both refusal tiers measured RED at the pre-cure tree — the seam minted `createAccessToken('user-1')`, the provider leg ran `updateLastLogin('user-1')` — plus the two unlatched controls). The disclosure guard `PasswordResetRecoveryReadmeTest` — whose per-sentence pins encoded the OLD bypass prose — rotated in the same commit: its endpoint-row pin now states the hold scope (the retired "do not consult the flag" sentence is pinned OUT), its refusal classifier runs ordered leg buckets (refresh → provider → password login; the naive flat 'login' check misrouted the provider sentence naming `loginWithProvider`), and a provider-leg sentence must now name the typed exception plus its fail-closed 503. Every pre-existing pin's presence (exception+500 password leg, 401-without-exception refresh leg, entry-point names, anti-fiction loop) is KEPT, re-scoped to the gated truth. - **The admin-settings 500 envelopes no longer echo raw exception text onto the wire — for a DB failure that text was the FULL SQL statement (review server__copywriter M5, M-SERVER-05).** `GET/PUT /api/v1/admin/settings` answered every unexpected failure with `'message' => $e->getMessage()`, and a settings-store blow-up surfaces as the `PDOException` re-thrown by `PhlixMySQLConnection::query()`, whose message is `'SQL:' . <the full statement> . <driver text>` — so any admin-authenticated client that tripped a DB error received table names, column names and statement literals in the response body. All four 500 arms (`index()`, `update()`, and the two auth-guard fail-closed arms of `updateAuthGuarded()`) now carry one generic operator message (`GENERIC_FAILURE_MESSAGE`) and hand the exception's class, message, file and line to the `http` log channel via a `logFailure()` seam — the estate's cured idiom (the Workerman router's last-resort catch and the Chromecast controller's L-6 sites already log detail this way). The envelope SHAPE is byte-identical (`{success:false, error:<same per-arm string>, message:<string>}`), status codes are unchanged, no wire error codes were added and `openapi.yaml` is untouched (contract law). The F7/B2 422 lock-out envelope deliberately KEEPS its curated `REASON_*`-derived contract message — it is validation payload, never infrastructure exception text, and is pinned byte-identically by the B2 law tests, which passed UNMODIFIED. `PhlixMySQLConnection::query()`'s SQL-embedding rethrow is server-side by design and load-bearing: `MigrationRunner::errorSegment()` classifies idempotent migration notes against the exact `'SQL:' . $statement . ' '` prefix (S159 direction-5, pinned by `MigrationFailureVisibilityTest`), so the format stays while the wire boundary is closed. `ChromecastController` was re-checked at tip: its evidence-named catch regions are the ALREADY-CURED L-6 sites (constant client message + logged detail), not a live leak. New `tests/Unit/Server/Http/Controllers/Admin/AdminSettingsControllerErrorEnvelopeTest.php` plants a SQL-shaped unique marker in each thrown message and asserts BOTH directions per arm (marker absent from the raw body AND present in the captured log); pre-cure it reddened exactly the four cured arms with the marker visible on the wire, and both mutation strips (restore raw echo / delete the log call) redden their pins. The one existing pin that encoded the leak (`testUpdateReturns500WhenRepositorySetThrows`, `assertSame('db down', …)`) was flipped to the new law. Census rows rotated from the measured red (2004→2005 files, 1096→1097 declared writes — both the new law file). - **`/api/v1/health/relay` and `/api/v1/health/network` are now authenticated (S437).** Both routes were registered with `$router->get(path, closure)` and no middleware, while every sibling `/api/v1/*` telemetry route is `AuthMiddleware`-gated. `relayHealth()` returns `relay.lastConnectError` / `lastConnectErrorAt`, whose raw text is recorded by `RelayConsumer::recordConnectError()` as `': ' . $e->getMessage()` — a socket-error string that embeds the hub host:port. Any anonymous caller could therefore read internal relay connection failures and hub network topology; S40 widened the surface by adding the latency snapshot. Both registrations now sit inside a nested `''`-prefix group carrying `[new AuthMiddleware()]`, so the METHOD+PATH tuples are byte-for-byte unchanged (only the route-level middleware column moves — the wire-path manifest records `[]` → `[AuthMiddleware]` for the two lines and nothing else). The sole consumer is the authenticated admin UI, so parity is preserved. Deliberate open liveness is untouched: the separate bare `/health` route (status/timestamp/version only) — the target of the Docker `HEALTHCHECK`, k8s probes and `scripts/docker-boot-smoke.sh` — stays unauthenticated, so no infra probe breaks. Pinned by `tests/Unit/Server/Core/HealthRoutesAuthGuardTest.php`: the production route table shows both gated, anonymous dispatch to each returns 401 `auth.required` with the health payload keys absent and the hub host/port/socket-error substrings absent, `/health` still answers 200, and the controller still returns the documented shapes when reached. (Census re-pinned 1771→1772 files and 949→952 declared Request writes in the same commit — the guard test names `Request` directly.) - **The last nullable admin middleware is gone — the family is closed (S338).** `MaintenanceController` was the S323-phase-2 note's "one instance survives", the sixth controller holding `private readonly ?AdminMiddleware $adminGuard = null` whose `requireAdmin()` returned "authorised" when the guard was absent. Its eight `/api/v1/admin/maintenance/*` handlers (three reads; five actions, two of them destructive: `cleanup-orphaned-stats` deletes rows and `dedupe-paths --apply` merges media items) are now all behind a **required, non-nullable, `readonly`** `AdminMiddleware` constructor parameter; the `=== null → return null` fail-open inside `requireAdmin()` is deleted and `checkAccess()` is called unconditionally. Constructing the controller without a gate is now an `ArgumentCountError` at the `new` — demonstrated in both the rewritten wiring test and the new structural pin, each with a three-argument positive control. **The exposure class is the logged-in NON-ADMIN, not the anonymous caller.** `requireAdmin()` refuses a null/empty `$request->userId` with a 401 `auth.required` BEFORE consulting the guard, so a gate-less construction would have degraded these routes to "any authenticated user" — the same class as S323 phase 2, lower than phase 1's anonymous exposure. Latent, as in every prior phase: production wiring always supplied the guard, and no exploitation is claimed. **Wiring simplified because the trap is structurally impossible.** The explicit `constructorParameter('adminGuard', get(AdminMiddleware::class))` binding in `AdminServicesProvider` is now a plain `autowire()` — PHP-DI skips OPTIONAL parameters, and there is no optional parameter left to skip. The two construction sites are enumerated: the provider's autowire and `AdminRoutes::register()`'s `$container->get()` (which resolves eagerly at bind time, so an un-suppliable gate fails loudly at boot, not silently per request). There was no live `$container->has()` guard for this controller. **The fail-open pin is inverted.** `MaintenanceContainerWiringTest`'s `test_an_unwired_controller_really_does_lose_the_in_body_admin_check()` demonstrated the old hazard (2-arg construction → null guard → non-admin gets 200). That construction path no longer exists; the test now proves the new contract — reflective two-argument construction throws `ArgumentCountError` (positive control included), and a wired controller refuses a non-admin with 403 `auth.not_admin`. **The seventh structural pin lands where the walker can see it.** `MaintenanceControllerAdminGateIsStructuralTest.php` lives at `tests/Unit/Server/Http/Controllers/Admin/` — inside the recursively-walked `PIN_DIRECTORY`, NOT beside the controller's other tests in `tests/Unit/Admin/Maintenance/` (which the S323-phase-2 recursion fix deliberately does not reach) — and `RouterDispatchableHandlersTest`'s pin count is bumped 6 → 7 in the same change. The pin uses the shared `RouterDispatchableHandlers` trait (never a copied helper), asserts its denominator (8 handlers, all gated — `UNGATED_REQUEST_HANDLERS` is empty), drives every handler anon/non-admin/admin, and carries two secondary nets: a per-handler source check that each gated handler reaches `requireAdmin()` (directly or via `runTask()`) and a global call-site count of 4 (`tasks`, `jobs`, `job`, and the single `runTask()` call that gates all five actions). All three regression shapes were demonstrated to fail it on scratch copies: a restored nullable/defaulted gate, a re-added `setAdminGuard()` setter, and newly added ungated handlers in BOTH escaping spellings (`mixed $request` native, and `Request|Response` union) — the drift detector enumerates 10 vs the asserted 8. A tokenized `?AdminMiddleware` scan over all of `src/` (comments stripped) now returns **zero** hits; the other eight raw hits are the S282/S323 conversion-comment docblock prose, which the tokenizer correctly ignores. - **The LDAP operation-timeout bound is now asserted to FIRE, not just declared (S328).** The `LDAP_OPT_TIMEOUT` option in `LdapConnection::connectionConfig()` had only config-presence coverage — no test opened a real LDAP socket, so a regression that kept the config literal present but rendered the bound ineffective at runtime passed silently. `LdapConnectionTimeoutTest` now carries two real-timing cases against a controlled dead dependency on loopback: a peer that accepts the TCP handshake and never answers consumes the 5-second operation bound (measured ~5.0 s, asserted `>= 0.5` and `< 8.0`), while a refused peer fails fast (measured < 0.5 s) — both fail with the identical `success:false, error:'ldap_error'` shape, so elapsed time is the only discriminator. Mutations prove the guard: removing the option turns the config assertions red and makes the real-timing case hang; bloating `OPERATION_TIMEOUT_SECONDS` to 15 turns the upper-bound assertion red at ~15.0 s. - **The admin gate on four more controllers is now a construction-time requirement, not an optional one (S323, phase 2 of 2 — S323 is complete).** `WebhookAdminController`, `MediaMatchController`, `MediaPosterController` and the TRaSH-Guides `Arr\SyncController` each held their `AdminMiddleware` in a **nullable property filled by an optional `setAdminMiddleware()` setter**, with `requireAdmin()` wrapping its decision in `if ($this->adminMiddleware !== null)` — the identical shape phase 1 removed from `ThemeMediaController`. A controller built without the setter returned "authorised" from `requireAdmin()` **without any admin decision having been taken**, across twelve endpoints: | Verb | Path | Controller | |------|------|------------| | GET | `/api/v1/admin/webhooks` | `WebhookAdminController` | | POST | `/api/v1/admin/webhooks` | `WebhookAdminController` | | PUT | `/api/v1/admin/webhooks/{id}` | `WebhookAdminController` | | DELETE | `/api/v1/admin/webhooks/{id}` | `WebhookAdminController` | | POST | `/api/v1/admin/webhooks/{id}/test` | `WebhookAdminController` | | GET | `/api/v1/media/{id}/match/search` | `MediaMatchController` | | POST | `/api/v1/media/{id}/match/apply` | `MediaMatchController` | | GET | `/api/v1/media/{id}/posters` | `MediaPosterController` | | PUT | `/api/v1/media/{id}/poster` | `MediaPosterController` | | POST | `/api/v1/admin/sync/trash-guides` | `Arr\SyncController` | | GET | `/api/v1/admin/sync/status` | `Arr\SyncController` | | PUT | `/api/v1/admin/sync/enable` | `Arr\SyncController` | **The severity here is LOWER than phase 1's, and the difference is stated rather than implied.** All four refuse an unauthenticated caller BEFORE consulting the middleware (`requireAuth()`, or the equivalent empty-`$request->userId` check), so the missing decision would have degraded these routes to **any authenticated user** — never, as in phase 1, to an anonymous one. As in phase 1 it was **latent and no exploitation is claimed**: every production wiring site called the setter. The change removes the possibility structurally rather than closing an observed bypass, because "the wiring calls the setter" is a property of today's wiring and not of the class, and PHP-DI's `autowire()` skips optional parameters. The null state is now unrepresentable on all four: `AdminMiddleware` is a **required, non-nullable, `readonly`** constructor parameter, `setAdminMiddleware()` is deleted, and `checkAccess()` is called unconditionally. Constructing one without a gate is an `ArgumentCountError` at the `new`. Every `if ($container->has(AdminMiddleware::class))` escape hatch is deleted — **four** of them, one each in `Application::getWebhookAdminController()`, `getMediaMatchController()`, `getArrSyncController()` and `getMediaPosterController()`. `MediaPosterController`'s second construction site, `WebPortalRouter`'s admin group, never had a `has()` guard: it called `setAdminMiddleware()` inside an `if ($this->userRepository !== null && $this->auditLogger !== null)` wiring guard that is fail-CLOSED (without those dependencies the poster routes are not registered at all — verified by exercise: 404 on every call), and that call is now a constructor argument. The dead container-less fallback in `getWebhookAdminController()` / `getArrSyncController()` is replaced by a loud `?? throw` at boot. A container that cannot supply the middleware now fails at route-registration time instead of yielding a working-but-ungated controller. **S282's `LibraryController` pin is re-based on the population that can actually regress.** It counted `requireAdmin()` CALL SITES and asserted 14 — a number that only rises when a handler is added WITH a gate, so it was structurally blind to a fifteenth handler added WITHOUT one. It now enumerates every handler the router can dispatch and requires each to be listed as either admin-gated or explicitly exempt; the call-site count survives as a secondary net, so nothing it caught was traded away. **The enumeration itself had a hole, found in review and closed in ONE place.** The "does this method take a `Request`" helper was six verbatim private copies matching a native type spelled `Request` or an `@param` mentioning `Request` — and asserting in its own docblock that the population was closed. It was not: `public function purge(mixed $request, array $params)` and `public function purge(Request|Response $request, array $params)` were each measured passing all six pins **and** `phpstan analyse -c phpstan.neon.dist` (src/, level 9), while being fully dispatchable. The predicate is now a single shared trait (`tests/Support/Http/RouterDispatchableHandlers.php`) that derives the answer from the dispatcher — "would PHP let `$instance->$method($request, $params)` reach this body?" — rather than from a list of type spellings, and it carries its own pin over a fixture of every shape, both escaping spellings included. The secondary call-site counts now run over tokenised source with comments stripped — as do the per-method source slices the null-guard checks read — so a comment quoting the counted literal can no longer inflate a count or stand in for the real call. (Tokenising removes the COMMENT class of that trap and only that class: a string literal or heredoc holding the same text still counts. Stated rather than glossed, and no controller does it.) **The pins that share the predicate are discovered recursively**, so the enforcement cannot be side-stepped by filing the next pin in a subdirectory. `RouterDispatchableHandlersTest` walks `tests/Unit/Server/Http/Controllers/` (all depths) for `*AdminGateIsStructuralTest.php`, derives each class name from its path, and asserts the pin count — 6 today, re-measured rather than assumed — plus that every pin found uses the trait. The previous flat `glob()` could not see a pin under `Controllers/Admin/`, which is where the next one would naturally land. **Existing tests that pinned the fail-open in EFFECT now run through a real gate.** `SyncControllerTest`, `WebhookAdminControllerTest`, `MediaMatchControllerTest`, `MediaPosterControllerTest` and `WebhookTestDeliveryTest` built these controllers with no middleware at all while sending an admin user id and asserting a handler outcome — so they depended on the null branch without ever saying so. Each now constructs a real `AdminMiddleware` admitting exactly the user id it sends (not a permissive stub), with every existing assertion unchanged. ⚠ **"S323 is complete" means its five named controllers, not that this shape is gone from the tree.** One instance survives, out of S323's scope and owned by **S338**: `src/Server/Http/Controllers/Admin/MaintenanceController.php` holds `private readonly ?AdminMiddleware $adminGuard = null` and its `requireAdmin()` returns "authorised" when that dependency is absent. Its exposure is lower — the eight `/maintenance/*` routes sit inside `AdminRoutes`' `[$adminMiddleware]` group, so the in-body check is a second gate rather than the only one — but that is a property of the current route registration and not of the class, which is the argument this change exists to reject. Recorded here rather than left in a worklog. - **The admin gate on the two theme-media mutation endpoints is now a construction-time requirement, not an optional one (S323, phase 1 of 2).** `POST /api/v1/libraries/{id}/theme-media/scan` and `DELETE /api/v1/libraries/{id}/theme-media` are admin-only. Until now `ThemeMediaController` held its `AdminMiddleware` in a **nullable property filled by an optional `setAdminMiddleware()` setter**, and each of the two handlers wrapped its decision in `if ($this->adminMiddleware !== null)`. A controller constructed without that setter therefore ran both handler bodies with **no admin decision having been taken at all**. **What that would have meant, stated precisely.** These two routes are registered bare in `Application::loadLibraryRoutes()` — there is no `AuthMiddleware` group and no `requireAuth()` ahead of the inline check — so the missing decision would not merely have degraded the routes to "any logged-in user": they would have been reachable by an **anonymous** caller. That is a more severe class than the same shape S282 removed from `LibraryController`, where an auth layer still stood in front. **This was latent, and no exploitation is claimed.** `ThemeMediaController` has exactly one production construction site (`Application::getThemeMediaController()`), and it always wired the middleware, so every controller this server has actually served requests from carried its gate. The defect was in what the class *permitted*, not in what the shipped wiring *produced*; the change removes the possibility structurally rather than closing an observed bypass. It matters because "the wiring calls the setter" is a property of today's wiring and not of the class, and because PHP-DI's `autowire()` skips optional parameters — the mechanism by which this codebase has produced silently-null dependencies before. The null state is now unrepresentable: - `AdminMiddleware` is a **required, non-nullable, promoted `readonly`** fourth constructor parameter; `setAdminMiddleware()` is deleted; `checkAccess()` is called unconditionally in both `scanThemeMedia()` and `deleteThemeMedia()` — there is no null branch left to take. Constructing the controller without a gate is an `ArgumentCountError` at the `new` (demonstrated, not asserted from reading), rather than a silent authorization downgrade at request time. - `Application::getThemeMediaController()` loses **both** escape hatches: the dead container-less fallback (`$this->container` is written exactly once, from a non-nullable constructor parameter, so that branch was unreachable — and it hardcoded a `127.0.0.1`/`root`/`password` connection) and the `if ($container->has(AdminMiddleware::class))` wiring guard. A container that cannot supply the middleware now fails loudly at route-registration time instead of yielding a working-but-ungated controller. `GET /api/v1/libraries/{id}/theme-media` is a genuine read path and is deliberately **not** admin-gated; that is unchanged, and is pinned as an explicit exemption rather than left implicit. **Why a structural test and not only behavioural ones.** A re-added setter that nobody calls changes no behaviour, so no behavioural test can see it: re-introducing an empty `setAdminMiddleware()` was measured to redden exactly one test — the structural pin — with the whole behavioural suite still green. `ThemeMediaControllerAdminGateIsStructuralTest` therefore rejects a nullable type, a default value, a re-added setter, and a re-added `if ($this->adminMiddleware !== null)` guard, and enumerates every public `Request`-taking handler on the controller so that a **new** handler added without a gate — including a `static` one, or one whose `Request` parameter is declared only in a docblock — fails the suite until it is classified as either admin-gated or explicitly exempt. Behavioural coverage pins all three arms (anonymous → `401 auth.required`, authenticated non-admin → `403 auth.not_admin`, admin → reaches the handler body) on both mutations, with the admin arm acting as the succeeding control so a blanket-deny regression cannot read as a pass. **This was phase 1 of 2, and it is superseded — read the phase-2 entry above for the current state.** When this shipped, the identical optional-`setAdminMiddleware()` shape was still present on `WebhookAdminController`, `MediaMatchController`, `MediaPosterController` and `Arr\SyncController`, and S282's `LibraryController` pin still enumerated `requireAdmin()` call sites rather than handlers. Both statements are now **historical**: phase 2 removed the shape from all four controllers, assessed their exposure (auth-only degradation, not anonymous), and re-based S282's pin on the wider enumeration. S323 is complete. - **OIDC callback hardened against open-redirect / token exfiltration** (updates.md #37 / S44). Wiring the previously-dead OIDC flow activated an unchecked `redirect_uri`. The callback now allowlists the post-login landing target to a **same-origin relative path only** (rejects absolute URLs, `//host`, `/\host`, `javascript:`, and any control/CRLF characters) at both `authorize` and the final 302, so a crafted `redirect_uri` can't phish the flow to an attacker origin. The minted session is delivered as **httpOnly + Secure + SameSite=Lax** cookies (`phlix_session` / `phlix_refresh`) — tokens are never placed in the URL, query string, or access logs. - **`ldap:`-prefixed logins are rate-limited and no longer enumerate accounts** (updates.md #37 / S44). Provider logins (`AuthManager::loginWithProvider()`) now share the same per-IP brute-force budget as local login (`checkRateLimit` / `record` / `clear`), and a `RateLimitException` maps to `429` (not a misleading `503`). Credential failures return a generic `401 "Invalid credentials"` (no user enumeration); only genuine config/connection problems (`ldap_error:` prefixed) return `503`. - **Credential-test responses can no longer echo a submitted plaintext credential.** `PluginAdminController::testCredentials()` hands operator-supplied secrets to third-party plugin code and then relayed that code's message — a returned string, a returned `message` field, or a caught exception's `getMessage()` — straight back in the JSON body. That is a plaintext-credential leak waiting to happen: HTTP client exceptions routinely embed the full request URI, and several provider APIs carry the API key as a **query parameter** (OMDb's `?apikey=…` is the in-tree example), so one `RequestException` would have put the operator's live key in the response verbatim. Every outgoing message now passes through `PluginAdminController::redactSubmittedSecrets()`, which replaces each submitted credential — literal form plus both URL-encoded spellings, since the value usually surfaces inside a URI — with `SettingsMasker::MASK`. A value is redacted when the manifest flags that setting `secret: true` (any length) **or** it is at least `REDACT_MIN_LENGTH` (8) characters. The second rule is deliberate defence in depth: `secret` is plugin-authored advisory metadata, so a manifest that forgets to flag a `password`/`token` field must not become a leak. The 8-character floor matches the password minimum already enforced elsewhere and keeps short non-credential values (`en`, `true`, a port number) readable in diagnostics. Tests assert the secret is **absent from the whole response body** — not that some masking flag was projected — across the thrown-exception path, the plugin-returned `message` path, the URL-encoded path, the not-flagged-but-long path and the flagged-but-short path, plus a negative test that a short non-secret value survives. Mutation-verified: reverting to the raw `getMessage()`/`message` relay turns five of them red with the plaintext key in the body. No credential is written to any log — this endpoint emits no audit entry and no log line. - **Pagination DoS hole closed — an over-large `?limit=` can no longer reach a `LIMIT ?` binding.** `Request::queryInt()` performed no bounds checking at all, and an unclamped page size flowed from nine list endpoints straight into `LIMIT ?`. Under Workerman the worker process is **resident and shared**, so `GET /api/v1/libraries/{id}/items?limit=100000000` was not a big page — it was a memory-exhaustion vector able to OOM the process serving every other user. New `Phlix\Common\Http\PageLimit` is now the single pagination policy, with a **hard compile-time ceiling** (`PageLimit::MAX = 100`) that no configurable default can raise; it is reached through the new `Request::queryPageSize()` / `Request::queryOffset()` helpers. `ItemRepository`'s existing (and previously only correct) clamp now delegates to it rather than keeping a second copy of the bounds. Applied across **both** dispatch paths: `MediaItemController` (`index`, `recentlyAdded`), `LibraryController::scanHistory`, `MediaUserDataController::listFavorites`, `AdminLiveTvController::listUpcomingRecordings`, and `WebPortalRouter`'s library-items, search, similar-items, because-you-watched, music-artists and music-tracks endpoints. `queryInt()` is deliberately left unbounded — it serves non-pagination params — which is why pagination must not use it. Regression tests assert the clamp per endpoint on both paths. - **Admin settings API no longer returns secrets in plaintext, and saving no longer wipes them.** `GET /api/v1/admin/settings` returned `getEffectiveMany()` raw, so every key flagged `"secret": true` in the shared schema (after the v0.24.0 re-vendor: `tmdb.api_key`, `lastfm.api_key`, `lastfm.shared_secret`) was shipped to the browser in clear text — present in the XHR body, in the DOM, and one "Show" click from being displayed, plus whatever proxy logs and HAR captures picked up. Secret values are now replaced with the existing `SettingsMasker::MASK` sentinel (the same mechanism the plugin settings path already used), and a new `data.secretStatus` map carries `{set, length}` per secret so the UI can still distinguish configured from unconfigured without seeing the value. Masking is driven by the schema's `secret` flag, **not** a hardcoded key list, so keys that gain the flag in a future `phlix-shared` release are covered automatically. Shipped in the same commit: `PUT` now skips any secret key resubmitted as the mask sentinel, leaving the stored value untouched — without that guard, fixing the GET alone would have made the first Save overwrite all three secrets with the literal `***`. - **SV-4.15 — per-surface auth rate limiting.** The previously-unlimited auth surfaces are now rate-limited: `register`, `refresh`, WebAuthn login `start`+`finish`, the public JWKS endpoint (`/.well-known/jwks.json`), and WS-connect on `:8097` (only `login` had a limiter before). HTTP surfaces reply **`429 Too Many Requests`** with a `Retry-After` header and body `{"error":"Too Many Requests","code":"rate_limited"}`; WS-connect rejects the handshake in-place (no throw out of the connect hook). Also **fixes a latent bug** where the existing DB-backed `login` limiter tripped with a **500** (uncaught `RateLimitException`) instead of a **429** — both dispatch entrypoints and `public/index.php` now map `RateLimitException` centrally. New operator config: a `rate_limit` block in `config/server.php` with per-surface env overrides `RATE_LIMIT_<SURFACE>_MAX` / `RATE_LIMIT_<SURFACE>_WINDOW`; a `trusted_proxies` setting (env `TRUSTED_PROXIES`, comma-separated IP/CIDR, default loopback-only) used to derive the real client IP from `X-Forwarded-For`/`X-Real-IP` for limiter keys — **it must list your reverse-proxy hops (nginx/HAProxy) or IP-keyed limits will bucket every request under the proxy address**; and a new migration **`085_rate_limit_buckets.sql`** (the shared DB-backed bucket table for the credential-enumeration surfaces — **run migrations on deploy**). Register/refresh/WebAuthn are DB-backed (true-global across all HTTP workers); JWKS and WS-connect are worker-local in-memory (JWKS is cache-frontable; the `:8097` WS worker runs `count=1` so per-worker == global there). `login` keeps its own IP-keyed DB-backed store (migration 074), untouched. - **Trakt OAuth binds the SERVER-WIDE account, so only an admin may complete it, and the completing admin must be the one who started the flow (M-4).** Both `/api/v1/oauth/trakt` rails were registered with no middleware and the controller checked nothing: any authenticated (or, absent a session cookie, anonymous) user could drive the PKCE dance and the callback overwrote `plugins.settings_json` for `phlix-plugin-trakt` — swapping the household's scrobble/sync identity to the attacker's Trakt account. Three closes: **(a) admin gate.** The factory now wraps the container's `AdminMiddleware` as the callback's gate — anonymous 401 `auth.required`, non-admin 403 `auth.not_admin` (both registered codes; no new literals), with the middleware's own permission-denied audit. The gate runs BEFORE the state lookup, so anonymous or refused traffic never burns the one-shot PKCE entry. A missing gate/`AdminMiddleware` yields `null`, which the controller treats as DENY (fail-closed — the S282 lesson, no "skip the check when unwired" arm). **(b) identity-bound state.** `authorize()` persists `$request->userId` into the state row — new nullable `user_id CHAR(36)` column on `oauth_state_store` via `migrations/110_oauth_state_store_user_id.sql` (additive, idempotent INFORMATION_SCHEMA guard, no FK — the table is TTL-transient and provider-unified); `callback()` requires the completing identity to `hash_equals` the bound initiator, refusing never-bound states (log reasons `state_not_bound_to_an_identity` / `state_bound_to_another_user`) — so an attacker cannot issue a state as themselves and have a logged-in admin complete it into their browser. `TraktOAuthStateStore::put()` gains the third optional parameter and a `consumeWithIdentity()` read; both the DB and session stores implement it and normalize an empty-string identity to `null` at the boundary. **(c) tokens at rest.** The documented `token_encryption_key` config (file/env) existed only in prose: the factory never passed `tokenEncryptionKey`, so every OAuth token landed in `plugins.settings_json` as PLAINTEXT. The factory now loads the documented key through `TraktOperatorConfig::load` (deliberately no `server_settings` overlay — the key is config/env-only) and hands it to the controller, whose `toStorageArray($cipher)` path encrypts with `SodiumTokenCipher` (`v1:`-tagged secretbox). The documented read-side contract — `decrypt()` passes any un-prefixed value through UNCHANGED, and returns corrupt/wrong-key payloads as-is rather than throwing inside a resident worker — is now pinned by `SodiumTokenCipherTest` (first test this cipher ever had), and `TraktOAuthFactoryWiringGuardTest` pins the wiring itself: gate is a Closure in production shape, anonymous/non-admin dispatch through the WIRED gate yields 401/403 with the registered codes, and a valid env key builds the cipher while its absence degrades exactly as documented. No migration data backfill: existing plaintext tokens stay readable via the passthrough and re-encrypt on the next refresh cycle. - **Book and audiobook progress endpoints actually work now (M-6).** `BookController` and `AudiobookController` resolved the user through a `setUserId()` seam that NOTHING in production ever called (the factories build the controllers per-route and never stamp identity), so every GET/POST `/api/v1/{books,audiobooks}/{id}/progress` answered 401 `Authentication required` to fully-authenticated users, and both POST handlers parsed `$request->query['body']` — a key that is never populated (the canonical parsed payload is `$request->body`) — so even the shape was dead. Worse, the two BOOKS rails were only ever registered in the long-dead `Router::books()` helper, so `BookReaderPage`'s saves 404'd into its silent-catch and reading progress never persisted anywhere. Handlers now resolve identity from `$request->userId` — the house pattern every sibling already uses — and parse `$request->body`; the dead property + setter are deleted; and GET+POST `/api/v1/books/{id}/progress` are registered inside the same `AuthMiddleware` group as the browse routes, bending the code TO the spec's declared behavior for the audiobook pair (`openapi.yaml` `x-phlix-middleware: [AuthMiddleware]`). `readBook`/`readAudiobook` resume progress off the request identity too, which — once the rails exist — restores the "continue where you left off" the UI was already coded for. Same-commit tripwires: the spec gains the books pair (currency 348→349 path templates), the composed-route inventory 373→375 (GET 196→197, POST 128→129), the wire-path manifest records both new rails with `[AuthMiddleware]`, and the Request census re-pins 1913→1916 files / 398→423 declared reads / 1000→1014 declared writes (all measured from the phpunit red, never predicted). Wire-verified by rewritten controller tests: request-identity GET/POST for a known user, anonymous 401 with the store `never()` touched, body-array payloads asserted at the store boundary, and clamp semantics (negative page → 1, percent clamped 0–100) pinned on the books side. - **`GET /api/v1/servers` is authenticated, and the unenrolled-server id stopped re-randomising per request (L-1).** The rail was registered with no middleware, leaking server name, id, hostname candidates and enrollment timestamp to anyone. Estate-wide search found exactly one consumer — phlix-ui's `AdminServersApi` (MetricsPage/ServerSelector), whose `ApiClient` defaults to the Bearer-sending `LocalStorageTokenStore` — and hub-relay traffic reaches the rail with a populated `$request->userId` (the `hub-relay` sentinel), so gating breaks nothing; the spec flips to `x-phlix-middleware: [AuthMiddleware]` + bearerAuth in the same commit, S437 sibling parity. The closure also minted a fresh `Uuid::v4()` on EVERY request whenever the server was neither enrolled nor configured with an id — the dropdown's server identity churned on every poll. That fallback is now memoised per process (enrollment/config ids still re-read per request, so enrolling takes effect without a restart). Pinned by `tests/Unit/Server/Core/ServersAndJobsAuthGateTest.php`: the route table shows `AuthMiddleware`, anonymous dispatch is 401 `auth.required` with the payload keys absent, and two dispatches on one Application answer with the SAME id. - **`/admin/health/jobs` is authenticated (L-2).** The F6 job-stats rail — transcode/scan running counts, oldest-job ages, reaper timing — sat under the `/admin` prefix while registered with no middleware at all. Zero consumers found anywhere in the estate except generated route manifests (contracts TS, client/hub test fixtures); infra probes target the deliberately-open bare `/health`, which stays unauthenticated (Docker `HEALTHCHECK`, k8s probes, boot-smoke). Gated in the nested `''`-prefix group so the METHOD+PATH tuple is byte-identical and only the middleware column moves (`[]` → `[AuthMiddleware]` in the wire-path manifest); anonymous 401 `auth.required` pinned by the same guard test as L-1. ### Security - **The plugin catalog's live fetchers now refuse HTTP redirects, closing the estate's last redirect-following egress family.** The catalog fetchers followed up to 3 redirects (cURL and stream transports) and — the async Workerman client carried no `allow_redirects` option at all — up to 5 by vendor default, so a vetted catalog host answering 302 could move the fetch (and its bytes) to an unchecked destination behind the moment-of-use SSRF check. All three transports now terminate on any 3xx with a refusal that names no URL. Healthy behaviour is unchanged: the default catalog and `plugin.json` URLs GitHub serves are 200-direct (measured), and the byte-audit network test now asserts the refusal on the redirector-shaped archive URL and re-hashes the artifact from its direct codeload target. Pinned by a live loopback fixture over all three transports (3 conviction legs measured red at the parent; the async legs execute where ext-swoole is present) plus the census file-count rotation. [EGRESS_TRANSPORT_LAW family: precedent 4187ff14 (R20-01), the R20-02 HttpInstaller posture, 87616b0f no-URL disclosure law; derived queue SRV-6; register: `.reviews/2026-11-03/FIX-DERIVED-SRV.md`] - **SSRF refusals no longer quote the exact destination they blocked in logs and delivery records.** The shared guard's refusal message names the full URL it examined, and the webhook subsystem fed that raw message into persisted surfaces: `webhook_deliveries.response_body` (first attempt and retry) via `WebhookService::ssrfRefusal()`, `webhook_logs.error_message` via `WebhookDispatcher::sendToWebhook()`, and the MQTT plugin's blocked-notification warning. All three now replace the URL with `<destination-redacted>` — the idiom the notification family's delivery-failure log already used; the verdict's host/IP prose and the `SSRF guard blocked URL:` prefix survive. The webhook-admin test-endpoint failure list deliberately keeps the raw message: that response already carries the URL as its own structured key, visible only to the operator who configured it. Red-first pins across the three existing SSRF suites (5 convictions at parent, zero collateral); the MQTT arm has no logger seam, so its law is source-pinned. [review server__secaudit derived-queue, SRV-m44; register: `.reviews/2026-11-03/FIX-DERIVED-SRV.md`] - **A relayed request can no longer pick its own rate-limit key or wire scheme through forwarded headers.** The hub-relay dispatcher asserts a trusted loopback peer for every relayed request — the right call for the tunnel, but it means the trusted-proxy IP resolver's peer gate cannot protect this path, and only the header strip can. The strip previously removed `X-Forwarded-For` (via the shared envelope DTO) but not the rest of the family the server itself consumes: an injected `X-Real-IP` reached every IP-keyed limiter and the DLNA allowlist through the resolver's `X-Real-IP` fallback, and an injected `X-Forwarded-Proto` reached the OAuth callback derivation and the Quick-Connect advertised URL ungated. `RelayConsumer::buildRequest()` now strips the whole `x-forwarded-*` prefix plus `x-real-ip` before dispatch — mirroring the hub's own edge list, and independent of it (the S301 doctrine: the server must not depend on the peer's strip). Healthy deployments see no behavior change: the hub already removes these headers inbound. Pinned by four relay-seam tests including an ordinary-headers control against over-stripping. [review server__secnet lineage, SRV-M16 forwarded-trust unit; fix measured red 2/4 at parent; direct-exposure site class verified already mitigated by the SV-4.15 resolver — proof register: `.reviews/2026-11-03/FIX-SRV-M16.md`] ### Known gaps (documented, NOT fixed) - **`restart: true` settings still do not take effect on restart.** The restart endpoint now works, but a reload cannot apply boot-only settings, because `start.php` `include`s `config/server.php` once in the master and nothing ever merges `server_settings` DB overrides into that array — and a full `systemctl restart` does not either. This affects every `restart: true` schema key. The fix is an architectural change (rewire each consumer to `getEffective()`, or overlay the overrides onto `$config` inside `onWorkerStart`) and is deliberately out of scope here. Full write-up, including the two candidate designs: **`docs/dev/settings-restart-gap.md`**. Until it lands, do not describe a `restart: true` key as taking effect after a restart. - The restart endpoint has no rate limit and emits no audit-log entry; a reload also cycles the SyncPlay WebSocket and DLNA SSDP workers, dropping live sessions, with no warning in the UI. ## [1.2.3] — 2026-07-12 ### Fixed - **Plugin-safe `LoggerInterface` binding fix.** Added `LoggerInterface::class` as an alias to `StructuredLogger::class` in `CoreServicesProvider` so plugins that request `Psr\Log\LoggerInterface` from the container resolve correctly instead of failing to autowire. ### Added - **Plugin test credentials endpoint.** New `testCredentials` endpoint in `PluginAdminController` allows the admin UI to verify whether plugin credentials (e.g. API keys) are valid before saving them. - **Plugin `redirect_url` support.** `PluginLoader` now reads and validates the optional `redirect_url` field from plugin manifests, enabling OAuth-style callback URLs in first-party plugins. ## [SV-0.2] — 2026-07-10 *Not a SemVer release: `SV-<n>` labels are internal track-milestone numbers (SV-0.x … SV-4.x recur as step IDs in later entries). This block sits in chronological position between the releases it shipped alongside; its content is preserved verbatim.* **Reconciled hardware acceleration config sources.** Consolidates conflicting hwaccel settings from multiple config files into a single source of truth. ### Changed - **`config/hwaccel.php`** is now the single source of truth for hardware acceleration settings, providing the `HwAccelConfig` class with a `get()` method that merges base hwaccel settings with transcoding-specific settings - **`HwAccelConfig::get()`** merges `config/hwaccel_base.php` (enabled, prefer_hardware, vendor_priority, probe_timeout, test_clip_path, fallback_to_software) with `config/transcoding.php` settings (tone_mapping_mode, preferred_accelerator, prefer_hdr_output, probe_timeout, test_clip_path, include_software_fallback) - **`config/ffmpeg.php['hwaccel']`** is deprecated and now delegates to `HwAccelConfig::get()` for backward compatibility — runtime code should use `\Phlix\Config\HwAccelConfig::get()` directly - No more contradictory `enabled` flags between config sources at runtime ## [1.2.2] — 2026-07-10 ### Changed - **Admin SPA bundle rebuilt against `@phlix/ui` v0.79.0** (`web-ui/` pin bumped `v0.78.0`→`v0.79.0`; committed `public/assets/app/` regenerated). Ships the plugin configure/lifecycle UX: persistent enable-failure banner with the server's real reason, secret fields that show set/unset + length (never the value), and rendered field-help links/defaults/"optional" markers. Version `1.2.1`→`1.2.2` (PATCH — bundled asset refresh, no server API change). ## [1.2.1] — 2026-07-10 ### Fixed - **Plugin manifests may now declare per-setting `link`/`link_text` field-help keys.** Bumped `detain/phlix-shared` to `^0.19.0`, whose manifest schema adds those optional keys. Without this, a plugin that shipped a "where to get this value" link in its own `plugin.json` failed install-time validation as "manifest is invalid" (the settings-entry schema is `additionalProperties:false`). The configure form already rendered such links via the server-side overlay; now they can travel in the manifest too. ### Changed - **Official plugin catalog pin advanced `v2.1.2` → `v2.1.3`** (`CatalogSourceResolver::OFFICIAL_PINNED_REF`): all 8 first-party plugins are repinned to their releases that pair with the 1.2.0 plugin-enable fix (lastfm 1.1.0, opensubtitles 0.2.0, anidb 0.3.0, myanimelist 0.2.0, omdb 0.2.0, trakt 1.2.0, anilist 0.2.0, musicbrainz 0.2.0), so the admin Plugins section installs/updates the enableable versions. - Version bumped `1.2.0`→`1.2.1` (PATCH). ## [1.2.0] — 2026-07-10 **Plugin enable + configure fixes.** Fixes the long-standing bug where installed plugins could not be enabled and never received their saved settings. ### Fixed - **Plugins now receive their persisted settings when enabled.** The loader instantiates each plugin entry class through the PSR-11 container (autowiring), then — this is the new part — calls the plugin's settings hook with the persisted `settings_json` map **before** `onEnable()`. A plugin opts in by implementing the new `Phlix\Shared\Plugin\ConfigurableInterface` (`configure(array $settings): void`, phlix-shared v0.18.0) or, transitionally, by exposing a public `configure(array $settings)` method. Previously nothing delivered settings to a plugin instance, so an enabled plugin ran with empty configuration (e.g. OMDb threw "API key not configured" even when a key was saved). - **Plugins whose constructor requires the settings array can be enabled again.** A plugin whose entry constructor takes the settings array as a required parameter (e.g. `__construct(array $settings)`, as `phlix-plugin-anidb`/`-myanimelist` did) cannot be autowired — PHP-DI cannot guess an `array` value, so `container->get()` threw and enable failed with `plugin.enable.failed`. `PluginLoader` now catches that and retries via the PHP-DI factory, binding the persisted settings to the first `array`-typed required constructor parameter, so such plugins enable without a re-release. When the fallback does not apply (e.g. a scalar-first constructor) the real PHP-DI resolution message is surfaced verbatim in the `PluginEnableException` so the operator sees the actual reason. ### Added - **Secret settings now report set/unset + length to the configure UI.** `GET /api/v1/admin/plugins/{name}` gains a `secret_status` map (`{ key: { set: bool, length: int } }`) alongside the masked `settings`. Every secret still masks to `***` in `settings` (the value is never sent), but `secret_status` lets the admin UI show whether a secret is actually stored and render a length-appropriate cue — so a saved key is distinguishable from an empty one, and a save is visibly confirmed. `SettingsMasker::secretStatus()`. - **Server-side plugin field-help overlay.** New `config/plugin_field_help.php` + `Phlix\Plugins\PluginFieldHelp` merge curated labels, richer descriptions, and "where to get this value" links (`link`/`link_text`) over each plugin's manifest settings schema in the configure endpoint, so already-installed plugins show the improved help immediately without waiting for a plugin update. `SettingsMasker::schema()` also passes through `link`/`link_text` when a manifest declares them, so enriched manifests carry the same help natively. ### Changed - Bumped `detain/phlix-shared` to `^0.18.0` (adds `ConfigurableInterface`). - Version bumped `1.1.0`→`1.2.0` (MINOR — new backward-compatible functionality; no server↔hub wire-compatibility impact). ## [1.1.0] — 2026-07-08 *First 1.x section in this file. The 1.0.0 release exists as git tag `v1.0.0` (`43138f05`, 2026-05-19) but has no changelog section — it predates the Keep-a-Changelog discipline declared at the top of this file, and history is not retro-authored (a `0.11.0` section immediately precedes this one).* **Stream Quality + Adaptive Bitrate (ABR) release.** Ships the full server-side pipeline built across this program's Tracks A, S, and D: a source-clamped multi-variant HLS ladder with on-demand per-variant segment encoding and genuine stream-copy passthrough for compatible sources (A1-A7), a client-facing `variants[]`/`quality_ladder` API surface, a batch of performance work spanning job-row/facet caching, streamed segment serving, non-blocking/parallel scan probing, materialized sort/genre indexes, and a validated coroutine DB connection pool (S1-S10), a genre-index risk redesign after a real InnoDB issue surfaced under stress (S7b), and hub proxy improvements for streaming paths (D1-D4). See the sections below for the full accumulated detail. Version bumped `1.0.0`→`1.1.0` (MINOR — entirely new, backward-compatible functionality; no server↔hub wire-compatibility impact). Also fixes a real drift bug found while preparing this release: the `/health` and `/system/info` endpoints hardcoded a stale `'1.0.0'` literal independently of `Phlix\Common\Version::STRING` (the documented single source of truth) — both now reference the constant directly, so a future version bump can no longer silently miss these two endpoints. ### Removed - **Dead blocking/legacy linear-transcode path removed from `TranscodeManager`/`FfmpegRunner` (Stream Quality/ABR step S10, Track S — pure cleanup, no behavior change; Track S is now FULLY COMPLETE, S1-S10).** The on-demand, seek-aware HLS/ABR pipeline built across A1-A7 and S1-S9 fully superseded the original blocking, single-linear-encode transcode path; this step deletes the now-zero-caller remainder rather than leaving it to bit-rot alongside the live code. - **`TranscodeManager`** (2800 → 2467 lines): removed `startTranscode()` (the blocking linear entry point) and its `$activeJobs` in-memory registration/gate, `stopTranscode()`, `cleanupStaleJobs()`, `getActiveTranscodeCount()`, `getMaxConcurrentTranscodes()`, `getTranscodeStatus()`, `normalizeSourceInfo()`, and `normalizeProfile()` (a distinct method from the still-live `QualitySelector::normalizeProfile()`, untouched) — all confirmed to have zero real callers repo-wide, reachable in practice only via test reflection. The `$activeJobs` map itself, plus the unrelated `$globCache`/`GLOB_CACHE_TTL` static glob-memoization it required, are gone too. The still-live on-demand concurrency gate (`$maxConcurrentTranscodes` property + `getRunningJobCount()`) is unaffected. - **`FfmpegRunner`** (1495 → 1426 lines): removed `transcode()`, the blocking `proc_open()` + `stream_get_contents()` method that was `startTranscode()`'s only caller. `buildTranscodeCommand()` — public, independently tested, and referenced by `SoftwareProfile.php` as a behavioral-parity contract — was deliberately left fully untouched. - **`TranscodeManager`'s constructor lost its `EncodingHelper $encodingHelper` and `string $transcodeDir` parameters** (previously 2nd/3rd positional args) — an internal, DI-resolved constructor, not a public or wire-facing API, so this carries **no client/API impact whatsoever**. Both params became write-only (unread by any surviving method) once `startTranscode()` — their sole reader — was deleted; removing them was forced by static analysis rather than optional tidying. All 5 real construction sites (the DI provider plus 4 test constructors) were updated to match. - **`ItemRepository::getExcludingGenres()`** removed (2409 → 2360 lines) — pre-existing dead code (zero callers repo-wide) flagged with an `@todo S10: DELETE` note back in step S7, and carrying its own long-standing param-count bug (a computed `$genrePlaceholders` string that was never interpolated into the query, so the bound values silently over- or under-matched blocked genres). No behavior was relied upon; no test covered it. - The in-worker job-row cache's `invalidateJobRowCache()` call-site count drops from 5 to 3 (completion, legacy-failure, reap) now that `startTranscode()`'s and `stopTranscode()`'s sites are gone with them; the `$jobRowCache` and `invalidateJobRowCache()` docblocks were updated to match. One test, `testCancelInvalidatesCachedJobRow()`, was removed since none of the 3 surviving call sites is a behavioral analog for "cancel" — the other 3 sites already have dedicated regression tests. - **Deliberately left untouched, so a future reader doesn't mistake completeness gaps for oversights:** `HlsStreamer`/`DashStreamer` and the CMAF `Dash/` subtree — confirmed live (LiveTV relay + DLNA), not the dead code the original cleanup plan assumed; the `chunk-*.m4s` glob inside `countSegments()`/the reaper — kept for backward compatibility with on-disk segment artifacts from pre-rewrite jobs; `FfmpegRunner::buildTranscodeCommand()` — kept as the documented `SoftwareProfile.php` parity contract, with its own dedicated tests; and `Application.php`'s 7 `ConnectionPool::getConnection('mysql')` DI-bypass call sites — assessed and deliberately deferred as a materially different, higher-risk kind of change, not "nearby" to this diff. - **New orphan flagged for a future cleanup step (not fixed here, deliberately out of scope):** with `TranscodeManager`'s ctor no longer consuming it, the `EncodingHelper` class and its standalone DI registration are now fully unconsumed by any caller. - Full gate green: phpcs/phpstan level 9 clean, the full combined phpunit suite (4,858 tests) green, line coverage 62.34% — a slight *increase* over the post-S9 62.11% baseline, consistent with deleting untested/lightly-tested dead code (smaller denominator, no covered lines lost). ### Added - **API surface now advertises the multi-variant quality ladder (Stream Quality/ABR step A7) — the client-facing contract this program has built toward since A1, and the step that closes out Track A: the entire server-side ABR pipeline (A1 source-metadata capture → A2 ladder builder → A3 schema → A4 per-rung FFmpeg encode/copy → A5 multi-variant playlists/segments → A6 variant-aware serving → A7 this API surface) is now code-complete end to end.** - **New `TranscodeManager::getJobVariants(string $jobId): ?array`** is the single source of truth for "what's playable for this job." It reads the persisted `transcode_jobs.variants` column (A3's schema, populated by A5) and mirrors `LadderResult::streamVariants()`'s dedup rule exactly: a genuine stream-copy "Original" is a real additional highest variant and is prepended, while a non-copy "Original" that merely duplicates the top clamped rung is **not** listed separately (nothing can request it as distinct anyway — see A6's `findRenditionArray()`). Each entry is the flat `Rendition::toArray()` shape (`id`/`label`/`width`/`height`/`bitrate`/`codecs`/`is_original`/`is_copy`/`video_bitrate`) with `url` filled to that variant's own relative, **unsigned** media-playlist path (`/hls/{jobId}/media_v{id}.m3u8`) — the first point in the pipeline any `url` is actually populated (A2 and A5 leave it `null`). Defensive against a missing/empty/corrupt `variants` column: any of those cases returns `null` (a legacy job) rather than throwing. - **`POST /api/v1/media/{id}/transcode` (`start()`) and `GET /api/v1/transcode/{jobId}/status`** both gain a `variants` key: the signed variant list from `getJobVariants()` — each `url` signed with the same prefix-scoped `SignedUrl` signer as `master_url`/`hls_url`, via a new private `TranscodeController::signVariantUrls()` — or an explicit `null` for a legacy `variants IS NULL` job (an explicit key, so a client can reliably branch on `!= null` rather than guess from key absence). Fully additive: no existing response key changed or removed. - **`GET /api/v1/media/{id}/playback-info` gains a `quality_ladder` key** — a **pre-flight preview** of the ladder a play would produce, built purely from A1's persisted `metadata_json['source']` blob (no `ffprobe` call, no transcode job created). Every entry's `url` is `null`, since nothing is playable yet — a deliberately different key from a real job's `variants[]` (per-item, not per-job). Resolves to `null` when the item lacks usable persisted source metadata (not yet scanned with A1, or missing width/height) — graceful degradation, not an error. Device-profile resolution is byte-identical to `TranscodeController::start()`: an explicit `?profile=` wins, else it is derived from the `X-Phlix-Device-Type` header via new `MediaItemController::mapDeviceTypeToProfile()` (`samsung-tizen`/`tizen`/`roku` → `tv-4k`, `android`/`ios` → `mobile-high`, `windows` → `generic`, anything else/missing → `web`); a controller test asserts the two controllers' mapping tables stay identical so the preview and the real job never disagree on profile. - Player-visible quality selection is still ahead — step **E3** in `@phlix/ui` and the native clients (**G4** Roku, **G5** console) — but `@phlix/contracts` (**B1**) can now mirror this exact, shipped response shape instead of a planned one. ### Changed - **The coroutine DB connection pool (`config/database.php` `connections.mysql.pool_enabled`) is now ON by default (Stream Quality/ABR step S9, Track S — the last-but-one step of Track S, gated per the plan on the throughput/coherence audit below; only cleanup step S10 remains).** Previously every coroutine in a worker shared one physical `PhlixMySQLConnection`, serialised on that connection's own per-connection coroutine mutex — every DB round-trip in the worker, including on the hot HLS segment/playlist and job-status paths, queued behind whichever query happened to be in flight. `PooledMySQLConnection` (a `Workerman\MySQL\Connection`-shaped front that leases a real connection out of a bounded per-worker pool for the life of the current coroutine) has existed since Track S began, deliberately left `pool_enabled=false` pending exactly this validation pass — S1 (job-row cache) and S5 (genre-facet cache) both flagged their own invalidate-on-write coherence proofs as tied to the single-connection-mutex assumption and explicitly called out S9 as the point those proofs would need re-examining. This step is that re-examination, done as a genuine audit rather than a flag flip: - **The audit found one real coherence gap and fixed it.** `TranscodeManager`'s in-worker job-row LRU cache (S1) relied on the shared connection's mutex to guarantee a cache-miss `SELECT` and a concurrent status-write `UPDATE` could never interleave; under the pool, a reader and a writer hold *different* physical connections, so a reader's in-flight `SELECT` can now return **after** a writer has already invalidated the cache for that job — without a guard, the reader would re-poison the (TTL-less) cache with its stale pre-write row, and that wrong row (e.g. a completed job still reading back `running`) would never self-correct until the next write to that same job. Fixed with a new per-jobId monotonic invalidation epoch (`$jobRowEpoch`): `invalidateJobRowCache()` bumps a job's epoch on every state write (its 5 existing call sites are unchanged — only its internal behavior changed), and `jobRowEntry()`'s cache-miss path snapshots the epoch immediately before its `SELECT` and only populates the LRU if the epoch is still unchanged when the query returns; a race is served as a one-shot uncached read to that single caller instead of being trusted. The snapshot-compare-populate sequence has no yield point, so it stays atomic under Swoole's cooperative scheduler even though the DB query itself is no longer mutex-serialised. `distinctGenres()`'s TTL+LRU genre-facet cache (S5) was independently re-checked and needed no change: every genre write already calls its own eager `invalidateGenreFacets()` synchronously with no intervening yield, so there is no equivalent miss-vs-invalidate race window for it to fall into. - **`PooledMySQLConnection` closed a real delegation gap surfaced by running CI's exact phpunit invocation (not a filtered subset) against real MySQL with the pool defaulted on.** The front previously delegated only `query()`, the `*Trans()` family, `lastInsertId()`, and `closeConnection()` to the coroutine's leased connection — production `src/` code was confirmed (by repo-wide grep) to only ever call `query()`, so this had never surfaced — but `tests/Integration/Media/BrowseIndexUsageTest.php` (an S7 test) calls `->row('EXPLAIN …')`, the one primitive `query()` doesn't special-case for a row-returning statement. Left undelegated, that call fell through to the un-constructed parent `Workerman\MySQL\Connection::row()` (this front deliberately never calls the parent constructor, so it has no socket/settings) and crashed with `SQLSTATE[HY000] [2002] No such file or directory`. Now `row()`, `single()`, and `column()` delegate to the lease exactly like `query()`; the class docblock also now explains why the fluent query-builder (`select()`/`from()`/`where()`/…) is deliberately *not* delegated — its per-instance builder state is incompatible with per-coroutine leasing, and Phlix never uses that form anyway (always `query($sql, $params)`). A repo-wide inventory of every method actually invoked on a `Connection`-typed value confirms the delegated set (`query`, `row`, `single`, `column`, the `*Trans()` family, `lastInsertId`, `closeConnection`) is now complete — nothing else was missed. - **Real parallelism proven, not just "didn't crash."** A new real-MySQL integration test (`tests/Integration/Media/Transcoding/PooledConnectionConcurrencyTest.php`, self-skips without Swoole/reachable MySQL) measures 6 coroutines each running `SELECT SLEEP(0.20)` against a `pool_size=6` pool completing in ~0.2s total (peak in-flight = 6) versus ~1.2s at `pool_size=1` — genuine concurrent execution, not an assumption. A second test hammers the same `transcode_jobs` row with 12 readers + 6 writers using the exact UPDATE-then-invalidate shape of all 5 real call sites, across different pooled connections, and asserts no torn/cross-query values, no 2014/"commands out of sync" errors, and — the epoch guard's specific claim — that both a fresh cache read and a direct DB read converge on the final written value once writes stop (the stale-forever bug the epoch guard exists to prevent). A third test drives 36 coroutines over a 4-connection pool to exercise the blocking `acquire()` channel-pop path under exhaustion with no deadlock or leak. A standalone (non-CI-gated) soak script additionally ran 18,000 read/write ops over 161s across 3 rounds with zero errors and full convergence every round. - **Fallback preserved and correctly distinguishes "unset" from "explicitly off."** `DB_POOL_ENABLED=0` (or `false`/`no`/`off`) restores the single-connection mutex path (`PhlixMySQLConnection`) exactly as before this step; `pool_size` still defaults to 8 and `pool_size=1` remains a safe, fully-serialised middle ground if a smaller blast radius than a full opt-out is wanted while diagnosing. The config's boolean parsing is deliberately `getenv('DB_POOL_ENABLED') === false ? '1' : getenv('DB_POOL_ENABLED')` inside `filter_var(..., FILTER_VALIDATE_BOOLEAN)`, not the repo's usual `?: '1'` idiom — the latter would treat the *string* `"0"` as PHP-empty and silently fall through to `'1'`, re-enabling the pool and making the documented opt-out unreachable; `=== false` correctly distinguishes a genuinely-unset env var (default on) from an explicit `"0"` (off). - The three already-audited coroutine/workerman-mysql traps (positional-bind re-keying, forced `utf8mb4`, emulated+buffered prepares — see `PhlixMySQLConnection`) were re-verified unchanged and correctly in place under every pooled lease; this step did not touch that class. - Full CI-equivalent gate green with the pool on: phpcs/phpstan level 9 clean, the full combined suite (4,859 tests) green against real MySQL 8.0.46, line coverage 62.12% (no regression versus prior Track S rounds, ≥40% gate). - **Bundled `web-ui/` SPA bumped to `@phlix/ui` v0.74.0 (Stream Quality/ABR step F1, Track F).** Brings the player-visible quality selector (`QualityMenu` wired into the control bar, Auto/discrete-rung/Original level switching via the hls.js level API, visual+a11y baselines — steps E1-E5) into the bundle the server serves at `/app/*`. No server-side code change; `web-ui/package.json`'s `@phlix/ui` pin moves `v0.73.1`→`v0.74.0` and `public/assets/app/**` is rebuilt and committed (Vite/Rollup content-hash chunk-name churn only across the whole bundle, not a regression — the non-deterministic-rebuild gotcha already documented against E5/F2). - **`MediaScanner::scanFlat()` fans out a bounded pool of concurrent ffprobes instead of probing one file at a time, and batches the already-scanned-path lookup into a single query (Stream Quality/ABR step S8, Track S — scan-throughput perf, no API/behavior change; builds directly on S6's non-blocking `FfmpegRunner::probe()`).** S6 made a single `probe()` call non-blocking (yield to the event loop instead of freezing the whole worker), but `scanFlat()` still issued and awaited those probes strictly one file at a time — under-using the very capability S6 added, since nothing stopped several non-blocking probes from running concurrently. Every candidate file in a scan batch also paid its own `ItemRepository::findByPath()` round-trip just to check whether it was already indexed. - **New `ItemRepository::findPathsMap(array $paths): array`** replaces the per-file `findByPath()` check with a single `WHERE path IN (?,?,...)` query per batch, returning `[path => hydrated row]` for every path that already exists (missing paths simply absent, not a null entry); empty input short-circuits without querying. Mirrors the existing `findByIds()` pattern. - **New `MediaScanner::probeManyConcurrently(array $paths): array`**, gated by the same coroutine-availability guard S6's `runProbeCommand()` uses (`extension_loaded('swoole') && class_exists(Coroutine::class) && Coroutine::getCid() > 0`). Inside a real Swoole coroutine, one `Swoole\Coroutine\Channel` sized to the concurrency cap acts as a semaphore and a SECOND `Swoole\Coroutine\Channel` (sized to the batch count) acts as a "done" signal that the caller pops once per path to join every launched probe — a `WaitGroup`-equivalent join built from two `Channel`s rather than `Swoole\Coroutine\WaitGroup` itself, because PHPStan's bundled swoole stubs (used when `ext-swoole` is absent, e.g. CI's PHPStan job) have no `WaitGroup` stub and fail `analyze --level=9` on it. A probe failure (thrown exception, or `Coroutine::create()` itself refusing to schedule under Swoole's `max_coroutine` ceiling) resolves that one path to `null` and releases its slot/signals done without stranding the pool or aborting siblings. Outside a coroutine (PHPUnit CLI, plain CLI scan scripts) it falls back to the exact pre-S8 sequential probe-per-path loop — behaviorally identical to before S8 in that context. - **New config knob `config/ffmpeg.php` → `max_concurrent_scan_probes` (default 4)**, wired through `MediaServicesProvider` into a new `MediaScanner` constructor parameter, mirroring the existing `max_concurrent_transcodes` knob's style/placement. `scanFlat()`'s directory walk is restructured into fixed-size batches (`SCAN_BATCH_SIZE = 200`, deliberately smaller than `DuplicateFinder`'s 500 since each candidate here may hold open a coroutine-pool probe slot) — concurrency is capped independently of batch size, and confined entirely to the read-only, DB-free ffprobe step; the create/dedup/`persistStreams` sequence for each file still runs sequentially, in original candidate order, exactly as before. - **Deliberately scoped to `scanFlat()` only — `scanSeriesPerDirectory()`/`scanSeriesDir()` (episode/series directory scans) are left fully sequential, not an oversight.** Those code paths can create a *shared* parent series/season row via `resolveEpisodeParent()` when two files in the same batch are the first to reference that container; making that path concurrent is a genuinely different (find-or-create race) problem than fanning out read-only probes, and is left for a dedicated future step. Documented in `scanFlat()`'s docblock and as an explicit scope note directly in `scanSeriesDir()`'s docblock. - Test coverage: +9 tests (`MediaScannerTest`: sequential-fallback-outside-coroutine regression, bounded-concurrency proof via a live in-flight counter, correct per-path result attribution under concurrency, single-probe-failure isolation, the `Coroutine::create()`-refusal edge case (mutation-tested — the fix's absence provably deadlocks), and the `processFile()` precomputed-probe plumbing; `ItemRepositoryTest`: `findPathsMap()`'s empty short-circuit, single-query/placeholder shape, and result keying). - **Genre filtering moves off the MySQL 8 multi-valued functional index (MVI) introduced by S7's migration 050 onto a normalized `media_item_genres` join table (Stream Quality/ABR step S7b, Track S — risk-driven redesign, no API/behavior change; supersedes only the genre-index portion of S7 — `sort_title`/`content_rating` and their indexes are untouched and were never implicated).** A dedicated stress test (50 rounds × 300 rows = 15,000 rows of create/cascade-delete churn against a prod-version-matched MySQL 8.4.10, modeling ~50 consecutive full rescans of a 300-item library) proved the risk this program's plan had flagged as needing empirical validation was real, not log-only noise: **29,900** `[MY-012869]` InnoDB purge-thread "record not found on update" errors, recurring continuously across 58 distinct one-second buckets spanning the entire run — up from 73 errors in a single small CI round — scaling with churn volume rather than converging to a fixed benign count. That fails the bar for "contained," so the MVI is replaced rather than accepted. - **New migration `migrations/051_media_item_genres_join_table.sql`** creates `media_item_genres (media_item_id, genre)` — `PRIMARY KEY (media_item_id, genre)`, `INDEX idx_media_item_genres_genre (genre)`, `FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE` — idempotently backfills it from every existing row's `metadata_json.$.genres` via `INSERT IGNORE ... JSON_TABLE(...)`, then drops `idx_media_items_genres` (the MVI). `metadata_json.$.genres` remains the single canonical source of truth (API responses and `MediaItemShaper` still read it directly, unchanged); the join table is a derived index only, kept in sync by `ItemRepository::insertGenreRows()` (INSERT-only, from `create()`) and `syncGenreRows()` (DELETE-then-insert, from `update()`). - **Read paths rewritten from `MEMBER OF`/`JSON_TABLE` to joins/`EXISTS` against the new table:** `getByAllowedGenres()` and `buildFilters()`'s genre predicate now use a correlated `EXISTS (SELECT 1 FROM media_item_genres ...)` (preserving the "allowed genre OR item has no genres at all" semantic), and `distinctGenres()` (S5's TTL+LRU-cached facet scan) now does a plain `JOIN` read instead of unnesting `JSON_TABLE` — the cache-miss SQL changed, the cache mechanism itself did not. - **Deliberate case-sensitivity split, not an oversight.** `media_item_genres.genre` is declared `VARCHAR(255) COLLATE utf8mb4_bin` specifically so the filtering predicates (`getByAllowedGenres()`/`buildFilters()`) keep the exact case/accent-**sensitive** exact-match semantics the old `MEMBER OF` predicate had — a plain `_unicode_ci` column would have silently loosened filter matching (caught in review by direct empirical comparison against real MySQL: `'action' MEMBER OF (...)` vs `Action` stored → no match under the old code; `WHERE genre IN ('action')` against a `_unicode_ci` column → a false match). `distinctGenres()` is the one deliberate exception: its facet-list query re-asserts `COLLATE utf8mb4_unicode_ci` explicitly on the selected/ordered column so the returned facet list stays case-insensitive-deduplicated exactly as it was pre-051 (e.g. `"Action"`/`"action"`/`"ACTION"` on the same item still collapse to one facet), independent of the now-case-sensitive storage/filter collation. Neither query-time `COLLATE` override touches the filtering `EXISTS`/`IN` predicates, so `idx_media_item_genres_genre` stays fully index-usable (verified via `EXPLAIN` on real MySQL 8.0.46 and 8.4.10 — no full scan either before or after). - `getExcludingGenres()` (pre-existing dead code, S10-owned) left functionally untouched; only its `@todo` reworded since it no longer references the removed `MEMBER OF` form. - `scripts/backfill-sort-metadata.php`'s genre-related comment corrected (comment-only) — it previously described the now-removed MVI as auto-deriving genre indexing; genres are now covered by migration 051's own idempotent backfill plus the write-path sync above, with no PHP CLI equivalent needed. - Test coverage: `BrowseIndexUsageTest` rewritten to assert (via real `EXPLAIN`) that the rewritten `EXISTS` genre-membership query resolves against `idx_media_item_genres_genre`/`PRIMARY` with no full scan, empirically verified against both MySQL 8.0.46 (CI's pinned image) and 8.4.10 (prod's version); +9 tests overall covering the case-sensitivity/collation split, `insertGenreRows()`/`syncGenreRows()` write-path sync, and the rewritten filter/facet SQL shapes. - Re-running this step's own stress harness against the new join-table design (rather than the MVI) is the closing condition for **I3**, the deploy step gated on this redesign shipping clean. - **Library browse/filter listings materialize the article-stripped sort key and content rating into indexed columns, and genre filtering moves onto a MySQL 8 multi-valued functional index (Stream Quality/ABR step S7, Track S — pure performance/hardening work, no API/behavior or ordering change; pairs with S5's genre-facet cache and S1's job-row cache). Note: the genre-index portion of this step was superseded by S7b above after a stress test proved it carries real InnoDB purge-thread risk under sustained churn — `sort_title`/`content_rating` (the rest of this entry) are unaffected and remain current.** Every library listing previously ordered `ORDER BY <SortTitle::sqlExpression('name') CASE/LOWER/SUBSTRING>` — a per-row function MySQL can never satisfy from an index, forcing a filesort on every page load — and genre/rating filters ran `JSON_CONTAINS(metadata_json, ?, '$.genres')` / `JSON_UNQUOTE(JSON_EXTRACT(metadata_json,'$.rating'))`, neither of which is index-usable, so both full-scanned `media_items`. - **New migration `migrations/050_media_items_sort_indexes.sql`** adds two nullable columns — `sort_title VARCHAR(255)` (the article-stripped sort key) and `content_rating VARCHAR(32)` (copied out of `metadata_json.$.rating`, named distinctly from the per-user `user_item_data.rating` to avoid ambiguity) — plus a composite index `(library_id, type, sort_title, name)` (a superset of the plan's `(library_id, type, sort_title)`: the trailing `name` removes the residual filesort the stable-paging tiebreak would otherwise force; verified with EXPLAIN — `Extra` is filesort-free) and a single-column `(content_rating)` index for rating filters/parental-control range scans. The migration also backfills both columns for existing rows inline (guarded `IS NULL`, so a re-run or a later run of the new CLI below is a no-op) via the exact CASE `SortTitle::sqlExpression()` emits, so historical ordering is reproduced byte-for-byte, not just approximated. - **Genres deliberately stay inside `metadata_json.$.genres` — no new join table.** Instead, the migration adds a MySQL 8.0.17+ **multi-valued functional index** (`ADD INDEX ((CAST(metadata_json->'$.genres' AS CHAR(255) ARRAY)))`), and `ItemRepository`'s genre filters (`buildFilters()`, `getByAllowedGenres()`) are rewritten from `JSON_CONTAINS` to `? MEMBER OF (metadata_json->'$.genres')` — index-resolvable, same case/accent-sensitive exact-match semantics as before. This was chosen over a normalized join table specifically to keep **S5's genre-facet cache coherent**: `distinctGenres()`'s `JSON_TABLE` facet scan (and its `invalidateGenreFacets()` TTL+LRU cache) already reads genres from this exact blob, so a join table would create a second genre-storage location that every genre write would need to keep in sync — and that S5 wiring stays completely untouched by this step. No genre backfill is needed either: the index is derived automatically from the existing blob. - **`ItemRepository::create()`/`update()`** now populate `sort_title` (via `SortTitle::from()`, whose output is branch-for-branch identical to the retired runtime `SortTitle::sqlExpression()`) and a new `extractContentRating()` helper populates `content_rating` on both the write path and the new backfill CLI, so live writes, the CLI, and the migration's inline backfill can never drift. `update()` mirrors the existing `canonical_key` lockstep pattern: a `name` change re-derives `sort_title`, a `metadata_json` (re)write re-derives `content_rating`, and an explicit caller-supplied value for either column always wins. The runtime `SortTitle::sqlExpression()`/`letterSqlExpression()` calls are fully removed from the query path (`titleOrder()`, the new private `letterExpression()`, `letterCounts()`, `valueBuckets()`) in favor of reading the materialized columns. - **New offline CLI `scripts/backfill-sort-metadata.php [--library=<id>] [--limit=<n>]`** — matches step A1's established backfill-script pattern (`updated`/`skipped`/`failed` buckets, skip-if-already-consistent, non-zero exit on any failure) — for populating `sort_title`/`content_rating` on rows that predate this migration on an instance where the inline migration backfill hasn't (yet) run, or was interrupted. - **Deploy caveat — read before running migration 050 on a live box.** Unlike this migration's other statements, the multi-valued genre index's `ADD INDEX` is **not** covered by `MigrationRunner::isExpectedIdempotentError()` and will **hard-fail the whole migration** if any existing row has a genre element longer than 255 characters or a `$.genres` value that isn't a JSON array. The migration's leading comment carries a "DEPLOY RUNBOOK NOTE" with copy-paste pre-flight SQL to detect and clean up any such rows before migrating; **the I3 deploy step must read and follow it.** - `getExcludingGenres()` — confirmed dead code repo-wide, and the only remaining un-indexed `JSON_CONTAINS`-based genre path — was left unmigrated to avoid scope creep into its pre-existing param-count bug; flagged with an `@todo` for a future cleanup pass. - Test coverage: unit coverage on `extractContentRating()`'s shape handling (array/string/missing/malformed), the create/update materialization write paths (mutation-tested), and the rewritten rating/genre SQL; a new self-skipping integration test (`tests/Integration/Media/BrowseIndexUsageTest.php`, mirrors `SortTitleOrderingTest`'s convention) asserts via `EXPLAIN` that the composite index is applicable with no filesort and the genre `MEMBER OF` predicate resolves against the multi-valued index. - **`FfmpegRunner::probe()` no longer stalls the whole worker, and `ensureHlsJob()` reuses A1's scanned source metadata instead of re-deriving it from a live probe (Stream Quality/ABR step S6, Track S — pure performance/hardening work, no API/behavior change; pairs directly with A1's source-metadata capture).** Previously `probe()` executed ffprobe via a plain `shell_exec()`. The server's coroutine hook mask (`SwooleRuntime::SAFE_HOOK_NAMES`) is a deliberate **allowlist**, not a blocklist, and does not hook `proc_open`/`exec`/`shell_exec` — so that blocking call froze the *entire* Workerman worker (every concurrent connection) for the full duration of the ffprobe process. `probe()` sits on two hot paths: `TranscodeManager::ensureHlsJob()` (every play-start of a non-direct-play title) and the library scanner (per scanned file), so under real load this was a worker-wide stall on every play button press. - **Non-blocking dispatch (the core fix).** New private `FfmpegRunner::runProbeCommand()` runs the ffprobe command via `Swoole\Coroutine\System::exec()` whenever it is called inside a genuine coroutine (`Swoole\Coroutine::getCid() > 0`, matching the repo's established idiom) — a native coroutine primitive that yields to the event loop while ffprobe runs, so other connections keep being served, and which works regardless of the curated hook mask (chosen specifically because `proc_open` is *not* hooked here). Outside a coroutine — the CLI scanner/backfill, unit tests, any non-Swoole runtime — it falls back to the original blocking `shell_exec()`, which is correct there since there is no event loop to stall. Both paths run the command through `/bin/sh -c`, so the existing `escapeshellarg()` quoting and `2>/dev/null` redirect behave identically; `probe()`'s JSON parsing and public return shape are byte-for-byte unchanged. This alone benefits every caller of `probe()` (`ensureHlsJob`, the scanner, `SubtitleController`, `TrailerFinder`, `ThemeMediaFinder`, `TrickplayGenerator`, the legacy `startTranscode`), not just the two named hot paths. - **Skip the probe as source-of-truth when scanned metadata is fresh.** New `TranscodeManager::sourceMetadataFresh()`/`persistedDurationSeconds()` helpers check whether A1 already persisted real source dimensions (width + height > 0, mirroring the existing `sourceProfileForItem()` gate) and a positive `metadata_json['duration_seconds']`. When fresh, `ensureHlsJob()` takes the source duration straight from the scan — skipping both the probe-derived duration and the now-redundant idempotent `persistProbedDuration()` write — and the ABR ladder is already built from the persisted profile via the pre-existing `sourceProfileForItem()` preference. Crucially, a probe **failure** is now tolerated on this path: previously any `null` probe result threw `"Failed to probe media file"` and refused to play the title; now that failure only degrades the request (no embedded-subtitle sidecars for that request) when the scan already described the source well enough to build the job without it. Without fresh persisted metadata, a `null` probe still throws exactly as before. - **Known, deliberately scoped residual: the probe call is still issued even on the fresh-metadata path.** A1 persists only the video + primary-audio summary (`metadata_json['source']` / `media_streams`) — never subtitle stream descriptors — so embedded TEXT-subtitle detection (`SubtitleExtractor::detectTextTracks()`) still needs the live ffprobe stream list. Skipping the probe outright would silently drop embedded subtitles on the HLS path for backfilled items, which is unacceptable, so the probe is retained but is now (a) non-blocking and (b) no longer the source of truth for the ladder/duration and no longer a hard dependency when metadata is fresh. Fully eliminating this residual probe would require persisting subtitle-track descriptors at scan time (touching A1's persisted shape) — left as a small, well-scoped follow-up rather than reopening the twice-reviewed A1 surface here; the code carries an explicit warning comment at the `detectTextTracks()` call site so a future change doesn't remove the probe before that persistence lands. - Test coverage: `FfmpegRunnerTest` covers the non-coroutine `shell_exec()` fallback (JSON parse, null-on-missing-binary, null-on-non-JSON-output) and a real-coroutine test (`Swoole\Coroutine\run()`) that exercises the coroutine-exec branch itself — the actual "no worker-wide stall" mechanism, mutation-tested to confirm it genuinely drives that branch rather than silently degrading to the fallback. `TranscodeManagerTest` covers the fresh-metadata duration/ladder skip (persisted value wins over a stale probe value, no redundant UPDATE issued), probe-failure tolerance on the fresh path, and the preserved throw-on-failure behavior when metadata is not fresh. - **`ItemRepository::distinctGenres()` is now backed by an in-worker TTL+LRU cache (Stream Quality/ABR step S5, Track S — pure performance/hardening work, no API/behavior change; pairs with library work and, per the plan, is a prerequisite for S9's connection-pool validation).** Previously every genre-filter-UI load (`GET /api/v1/media/facets` → `WebPortalRouter::getMediaFacets` → `distinctGenres()`) unnested `metadata_json.$.genres` via a `JSON_TABLE` full scan of `media_items`, even though the genre set only changes when items are scanned or edited. - A bounded map (`private array $genreFacetCache`, `GENRE_FACET_CACHE_MAX = 256` entries) caches each `{genres: list<string>, expires_at: int}` result keyed by scope — a library UUID for a scoped call, or the `GENRE_FACET_GLOBAL_KEY` sentinel (`"\0all-libraries"`, chosen so it can never collide with a real UUID) for the unscoped/all-libraries call. TTL is `GENRE_FACET_CACHE_TTL_MS = 300_000` (5 minutes), measured against a new `monotonicMs()` helper (`hrtime(true)`-based, mirroring `TranscodeManager::monotonicMs()` and the repo's documented monotonic-clock convention) so the cache is immune to NTP/DST clock jumps. A fresh hit returns with zero DB access; a miss or expired entry falls through to the unchanged `JSON_TABLE` query and repopulates the cache. - **The 256-entry bound is a security control, not a tuning knob.** `WebPortalRouter::getMediaFacets` passes the raw, unvalidated caller-supplied `?libraryId=` query parameter straight into the cache key, and `ItemRepository` is a shared, per-worker-resident singleton (PHP-DI `autowire()`) — so the scope-key space is attacker-influenced. Without a bound, an authenticated caller could force unbounded resident-memory growth by cycling through fabricated library ids. Eviction is **genuine LRU, not FIFO**: both the cache-hit path and the recompute/populate path `unset()` the existing key immediately before reassigning it, which is required because PHP does not otherwise reposition an existing array key on a plain value reassignment — so the coldest, least-recently-*read* scope is evicted at the bound (`array_key_first()`), not merely the least-recently-*written* one. (An earlier round of this change got the recompute-path repositioning wrong — see the regression test below — and review/independent test-engineering passes closed it.) - **Invalidation** is wired into every write path in `ItemRepository` that can change the genre set, via a new public `invalidateGenreFacets(?string $libraryId = null)`: `create()` invalidates that item's library scope (`batchCreate()` is covered transitively, since it calls `create()` per item); `update()` invalidates globally (`null`, flushing every scope) but **only** when `metadata_json` is among the written fields — a metadata-free update (e.g. a rename) leaves the cache warm; `delete()` invalidates the item's library scope (or globally, when the library isn't known at the call site); `deleteByLibrary()` invalidates that library's scope. Any library-scoped invalidation also drops the global/all-libraries scope, since it spans every library's genres. A `null` argument always flushes the whole map. Cross-worker note: a scan running in a different worker process cannot reach this worker's in-memory map, so a scanner-driven genre change surfaces to a facet-serving worker after at most one TTL window (5 minutes) — this is the documented purpose of the TTL, not a gap; same-worker writers never observe their own stale cache thanks to the eager invalidation calls above. - Test coverage: 9 new unit tests cover cache-hit/miss behavior, independent per-scope caching, every invalidation call site (including the metadata-changed-vs-not `update()` branches), global-vs-library scope invalidation, and the exact LRU eviction-at-bound and stale-recompute-repositioning behaviors (both mutation-tested — reverting either fix makes its regression test fail as expected). New code reached 100% statement coverage. - **`HttpHandler` gzip-compresses text/JSON/HTML responses, tags content-hashed static assets as immutably cacheable, and measures request duration with `hrtime(true)` (Stream Quality/ABR step S4, Track S — independent quick win, no API/behavior change to any response body; media/streaming responses are untouched).** - **`Content-Encoding: gzip`** is applied by a new private `compressResponse()`, wired into the three buffered-response dispatch sites in `__invoke()` (the `Application` router response, the `WebPortalRouter` `/api/*` fallback, and the SSR page-rendering path). It compresses only when the client sent `Accept-Encoding: gzip`, the body is `>= GZIP_MIN_BYTES` (1024 bytes — below a single ~1.5 KB TCP segment, compression buys nothing on the wire while still costing CPU and the ~20-byte gzip envelope), the response isn't already `Content-Encoding`-tagged, gzip (level 6) actually shrinks the body, and — the key safety gate — the Content-Type is on a strict allowlist (`isCompressibleType()`: any `text/*`, plus `application/json`, `application/javascript`, `application/xml`, `application/manifest+json`, `application/ld+json`, `application/rss+xml`, `application/atom+xml`, `image/svg+xml`). On success it sets `Content-Encoding: gzip`, merges `Accept-Encoding` into `Vary` (`mergeVaryAcceptEncoding()`, dedup-safe), and rewrites `Content-Length` to the compressed size (dropping any stale case-variant header first). - **Media/streaming responses are never gzipped, verified by two independent guards.** Guard 1: `compressResponse()`'s first check is `$response->filePath !== null` — every HLS/DASH playlist and segment is served via `Response::withFile()` (S3), and direct-play byte ranges/avatars return raw `WorkermanResponse`s that never even reach this method, so a file-backed or already-bypassed response can never be buffered/compressed. Guard 2: the Content-Type allowlist has no video/audio/image/`octet-stream` entry and specifically excludes the HLS `application/vnd.apple.mpegurl`/DASH `application/dash+xml` playlist types (no `+xml`-suffix wildcard that could net `dash+xml`). Either guard alone excludes the entire streaming surface. - **`Cache-Control: public, max-age=31536000, immutable`** is now set by `serveStatic()` for any request resolving under the Vite-built `public/assets/app/**` bundle (content-hashed filenames — the bytes for a given URL never change, so a year-long cache with no revalidation is safe). The decision is gated on the **resolved, jailed filesystem path** (`$real`, the same value already used to serve the file and enforce the `publicRoot` jail), not the raw request-path string — a request whose path merely *starts with* `/assets/app/` but traverses outside it via `..` (e.g. `/assets/app/../foo.js`, or a sibling directory like `/assets/appendix/...`) does not get tagged immutable, even though `realpath()` doesn't normalize `..` in the raw string. Non-`/assets/app/` static files (favicon, robots.txt, etc.) are unaffected. - **Request-duration timing switched from `microtime(true)` to `hrtime(true)`** (the repo's documented monotonic-clock convention) — `__invoke()` captures `$startTime = hrtime(true)`; `recordRequestMetrics()`'s `$startTime` parameter is now `int` (was `float`) and computes `$elapsedMs` from the nanosecond difference before converting to milliseconds, so a long-running worker's large `hrtime` values keep full sub-millisecond precision and stay immune to system clock adjustments. Purely a precision/monotonicity improvement — the metric remains a duration, not a timestamp — and only this one request-timing site was touched (the repo's many other `microtime(true)`/wall-clock uses are unrelated and intentionally untouched). - **HLS/DASH segments and playlists now stream through Workerman's event-loop file sender instead of being buffered into worker memory (Stream Quality/ABR step S3, Track S — structural performance fix, no API/behavior regression; pairs with A6's variant-aware serving, and unblocks hub Track D's deferred `D3` streaming pass-through since the origin no longer buffers whole segments).** `TranscodeFileServer::serveJobFile()` (the trait shared by `HlsController`/`DashController`) previously read every playlist/segment body — including every ~1–6 MB `.ts`/`.m4s` HLS/DASH segment — whole via `file_get_contents()` and advertised `Accept-Ranges: bytes` while never actually honouring a `Range` request. Now: - **`Response` gained a file-backed mode** — `withFile(string $path, int $offset = 0, int $length = 0): self` plus new `$filePath`/`$fileOffset`/`$fileLength` properties. `toWorkermanResponse()` hands the path straight to Workerman's native `withFile()` (the same event-loop file sender direct-play already used via `serveMediaStream`), which streams the body chunked for files ≥ 2 MB and auto-derives `Content-Length`/`Accept-Ranges: bytes`/`Last-Modified`, plus `Content-Range` + 206 whenever an offset/length window is supplied. **No route registration or handler-construction change was needed** — the dual-entrypoint boundary (`public/index.php` vs `start.php`→`HttpHandler`→`Application::loadStreamingRoutes()`) is untouched, because `Response` itself now carries the file rather than the controllers being lifted out of the router. - **Real `Range` support**, via a new shared `TranscodeFileServer::parseRange()`: single ranges `bytes=A-B`/`bytes=A-` → 206 (an over-long `B` is clamped to `$fileSize - 1` per RFC 7233 §2.1 and served, not rejected); suffix ranges `bytes=-N` ("last N bytes") → 206, with an oversized `N` clamped to the whole file; a `start` at/past EOF, `start > end`, or a zero-length suffix → genuinely unsatisfiable → 416 with `Content-Range: bytes */{size}`; a multi-range or otherwise-unparseable header falls back to a whole-file 200 (an RFC-permitted fallback, not a special case the server has to reject). - **Conditional GET**: an `If-Modified-Since` matching the file's mtime now short-circuits to 304 — but only for immutable, `Cache-Control: public, max-age=31536000` segments; `no-cache` playlists/manifests (rewritable mid-encode) are never short-circuited. - **CGI/FPM fallback parity.** `Response::send()` — the non-Workerman code path, unreachable in production since streaming routes are Workerman-only — gained a private `finalizeFileHeaders()` (computes the identical `Content-Length`/`Accept-Ranges`/`Content-Range` + forced 206 that Workerman's own `withFile()` derives, traced against `vendor/workerman/workerman/src/Protocols/Http.php::encode()`) and a bounded-chunk `streamFileToOutput()`, so a file-backed `Response` degrades gracefully and answers a Range request identically no matter which entrypoint renders it. - **Structural resident-memory/GC win.** No segment or playlist body is copied into a PHP string on the request-handling path anymore, so a large concurrent HLS/DASH load no longer pins a full in-memory copy of every in-flight segment inside the resident Workerman worker. (Verified by code inspection — no `file_get_contents()` remains on the segment/playlist serving path; live resident-memory measurement under load is out of scope for this environment.) - Fully backward compatible: `HlsController::serveFile()`/`DashController::serveFile()` only gained a `Request $request` parameter threaded through to `serveJobFile()` (needed to read `Range`/`If-Modified-Since`); the S1 job-row cache, S2's per-variant in-flight dedup/global cap → `SegmentBusyException`/503, 404 self-heal, and the signed-URL middleware group are all upstream of `serveJobFile()` and untouched. - **`TranscodeManager`'s on-demand segment concurrency gate splits its two in-flight checks apart (Stream Quality/ABR step S2, Track S — pure performance/robustness work, no API/behavior change; pairs with S1's job-row cache).** `produceSegment()` gates a new segment launch with two checks, and they no longer share one mechanism: - **The per-segment DEDUP check (`segmentEncodeInFlight()`) — hit on every request, since it's what catches a client retry of a slow segment (the routine hls.js first-byte-timeout re-request) and makes it piggyback on the already-running encode instead of spawning a duplicate `ffmpeg` — is now memory-based**, not a filesystem glob. A new in-worker set (`$segmentEncodesInFlight`, keyed by the absolute final segment path, which embeds the `(jobId, variant, index)` tuple) is unioned with a periodically-refreshed cross-worker snapshot (`$globalInFlightSnapshot`), kept fresh by a new `reconcileInFlightSegments()` throttled to at most one glob per second. This removes the `glob('{final}.part-*')` call from the highest-frequency check on the hot retry/seek path entirely. - **The global concurrency CAP (`countInFlightSegmentEncodes()`) intentionally remains a real-time, whole-tree `glob()` on every new-launch decision — it was deliberately NOT converted to read from the same memory-based bookkeeping as the dedup check.** An initial version of this step did make the cap memory-based too; review caught that because the cap is enforced independently by each of the 14 HTTP worker processes, a shared ≤1-second-stale snapshot could let the fleet collectively overshoot the advertised ceiling by up to ~14x — and specifically during a seek storm, the exact scenario the cap exists to bound (the same class of cascade the prior on-demand HLS seek-cascade incident hardened against). The fix restored a live glob for the cap check, preserving the original ~100ms `.part-*`-visibility accuracy bound. **This means S2 does not eliminate all filesystem globbing from the segment hot path** — only the dedup/retry check's globbing is gone; the cap-check glob still runs on every actual launch decision (which happens only after the dedup check already found nothing in-flight for that exact segment, so it's reached far less often than the per-request dedup check). The net effect is still a real, strictly-better-or-equal reduction in glob calls for every traffic pattern versus pre-S2 — just narrower in scope than "zero hot-path globbing." - `produceSegment()`'s `try` block now starts immediately before the in-flight increment (previously it wrapped only the poll loop), so the `finally` that releases the slot is reached on any throw after the increment — not just a poll-loop failure — closing a leak window a prior version left open. - `SegmentBusyException`→503+`Retry-After`, per-`(jobId, variant, index)` isolation, and all other A5/A6 dedup/cap/sweep/HLS-serving behavior are unchanged from every caller's perspective. - **`TranscodeManager::getJobRow()` is now backed by an in-worker LRU cache (Stream Quality/ABR step S1, Track S — pure performance work, no API/behavior change; pairs with A5/A6's multi-variant on-demand HLS).** Previously every HLS segment/playlist request re-ran a `SELECT *` against `transcode_jobs` under the shared connection's coroutine mutex, even though a job row is written once at creation and thereafter only its terminal `status` changes — the dominant per-segment DB cost on a hot seek/playback path. Now: - A bounded map (`JOB_ROW_CACHE_MAX = 256`, oldest-first/MRU eviction) caches the narrowed row keyed by job id, and the parsed `variants` JSON ladder is memoised alongside it so repeat reads (`ensureSegment()`, `getJobVariants()`, …) never re-`json_decode` it — preserving the exact legacy `NULL`-vs-corrupt-JSON distinction A5 relies on. - The `SELECT` itself is narrowed from `*` to `JOB_ROW_COLUMNS` — `id, status, input_path, hls_dir, duration_seconds, segment_seconds, segment_params, subtitle_tracks, variants` — the exact union of columns every real call site reads (a new test pins this list against every call site so a future narrowing regression fails loudly instead of silently nulling a field). - The cache is invalidated at all 4 sites that mutate a job row: the terminal-status sync in `getJobReadiness()`, `reapStaleRunningJobs()`, `cancelTranscode()`/`stopTranscode()`, and the legacy `startTranscode()` failure path — so a cache hit is always coherent with the last write. - No explicit coroutine lock was needed: `TranscodeManager` is confirmed the sole writer of `transcode_jobs` (repo-wide grep), and the shared connection's existing coroutine mutex already serializes the query round-trip on a miss, ruling out a populate-vs-invalidate race under the **current single-connection model**. **This coherence guarantee is tied to that single-connection mutex** — when step **S9** later validates and enables the coroutine DB connection pool, this cache's invalidate-on-write design will need re-examination (parallel connections could interleave a populate with a concurrent write in a way the current mutex prevents); tracked as a known S9 follow-up, not an open issue here. - **`HlsController::serveFile()` now serves both segment filename shapes of an on-demand HLS job (Stream Quality/ABR step A6) — completes the server-side serving half of the multi-variant feature landed by step A5.** Two `seg-…\.ts` shapes are recognized and both route to the same `TranscodeManager::ensureSegment($jobId, $variant, $index)` (A5's signature), with identical back-pressure/self-heal behavior for either: - Legacy unprefixed `seg-NNNNN.ts` → `ensureSegment($jobId, null, $index)` — selects a `variants IS NULL` single-variant job, unchanged from before A5. - Multi-variant `seg-v{renditionId}-NNNNN.ts` (e.g. `seg-v1080p-00042.ts`, `seg-voriginal-00007.ts`) → `ensureSegment($jobId, '{renditionId}', $index)`, where `{renditionId}` is matched against a `[a-z0-9]+` allowlist — the fixed set of ids `AbrLadder` produces (`240p`…`2160p`, `original`). The regex is anchored and excludes `.`/`/`/`\`, so it is a defense-in-depth guard that cannot smuggle a path-traversal sequence even before the earlier `isSafeFilename()` gate; a filename that matches neither `seg-…` regex falls through to a plain static-file lookup that 404s (never reaches the transcoder). - Either shape gets the same `SegmentBusyException` → `503` + `Retry-After` back-pressure and a `null` result → `404` self-heal (the client retries once the segment materializes) — no behavior divergence between the legacy and multi-variant paths. - **Per-variant media playlists needed no controller change.** `media_v{id}.m3u8` (e.g. `media_v1080p.m3u8`), written up front by `TranscodeManager` alongside `master.m3u8`, already served correctly as a plain static file through the existing `serveJobFile()` path — no transcoder call — confirmed by a new test asserting `ensureSegment()` is never invoked for a playlist request. - This is the server-side HLS **serving** half of the multi-variant feature; the client-facing API surface that advertises the available `variants[]` list shipped in step **A7** (see above). Player-visible quality selection is step **E3** in `@phlix/ui`. - **`TranscodeManager` is now a genuine multi-variant HLS pipeline (Stream Quality/ABR step A5) — the actual multi-quality/ABR feature landing for the first time.** Previously every on-demand HLS job served exactly one quality (`master.m3u8` → `media_0.m3u8` → unprefixed `seg-NNNNN.ts`). `ensureHlsJob()` now: - Resolves the source's characteristics into a `SourceProfile` — preferring A1's persisted `metadata_json['source']` blob (via the new `sourceProfileForItem()`/`persistedSourceMetadata()`), and only falling back to deriving one from the live `ffprobe` result (`sourceProfileFromProbe()`) for items that predate the A1 backfill — then calls A2's `AbrLadder::build()` to get a `LadderResult` and persists it as JSON in the (A3) `transcode_jobs.variants` column. - Publishes a `master.m3u8` listing **every** clamped quality rung plus the "Original" stream-copy passthrough (when the source is HLS-safe H.264/AAC) as separate `#EXT-X-STREAM-INF` entries, highest-first, each with a correct `BANDWIDTH`/`RESOLUTION`/`CODECS` and its own media playlist (`buildMultiVariantMaster()`). Each variant gets its own VOD media playlist — `media_v{id}.m3u8` (e.g. `media_v1080p.m3u8`, `media_voriginal.m3u8`) — with an **identical segment timeline** (count/`EXTINF`/duration) to every other variant, so hls.js can switch rungs at any segment boundary. Segment boundaries were already kept identical across rungs by A4; A5 is what actually exposes multiple rungs to a player. - Serves each variant's segments on demand from `ensureSegment(jobId, variant, index)` (now variant-aware): the requested variant id is resolved against the persisted ladder (`findRenditionArray()`), its encode params are derived (`segmentParamsForRendition()` — the copy contract for a genuine passthrough rung, or capped-CRF H.264/AAC with the rung's scale/VBV ceiling/macroblock-derived `-level` otherwise), and the shared tail (`produceSegment()`) writes `seg-v{id}-NNNNN.ts` (still flat in the job directory — the `/hls/{jobId}/{file}` route's `{file}` segment is `[^/]+`, so no `v{id}/` subdirectory is possible). All of the existing on-demand seek-cascade protections — per-segment dedup via `.part-*` temp files, the global in-flight-encode cap → `SegmentBusyException`/503, and the LRU/TTL cache sweep — continue to work unmodified across every variant of every job, because they already glob on the `seg-*`/`{final}.part-*` filename shape rather than assuming a single variant. - **Fully backward compatible.** Any transcode job created before this deploy (`transcode_jobs.variants IS NULL`) keeps working exactly as before: `writeVodPlaylists()`/`ensureSegment()` detect the absent `variants` column and fall through to the untouched legacy single-variant path — `master.m3u8` (single `#EXT-X-STREAM-INF`) + unprefixed `media_0.m3u8` + `seg-NNNNN.ts` — byte-identical to pre-A5. Nothing regresses for in-flight or existing jobs. - One small call-site change in `HlsController::serveFile()` (passes `null` for the new `$variant` parameter of `ensureSegment()`) kept the legacy unprefixed-segment regex match working at the time; full variant-aware URL parsing (`media_v{V}.m3u8` / `seg-v{V}-NNNNN.ts`) landed in step **A6** (see above). Player-visible quality selection UI is further out still (step **E3** in `@phlix/ui`) — this step was the server-side foundation, not yet user-facing. - **`FfmpegRunner::buildSegmentCommand()`: per-rung capped-CRF encode + genuine stream-copy passthrough (Stream Quality/ABR step A4) — `FfmpegRunner`-only groundwork at the time; the copy path was DORMANT until step A5 wired a real per-rung/Original decision into `TranscodeManager` (see the A5 entry above — `TranscodeManager::computeSegmentParams()`/`segmentParamsForRendition()` now make that decision for every job created after A5 deployed).** Two segment shapes, selected per-stream from the caller's params: - **Capped-CRF transcoded rung (the default, and byte-identical to the exact pre-A4 command when the new keys are omitted).** `maxrate`/`bufsize` (bps ints, sourced from A2's `Rendition::maxrate()`/`Rendition::bufsize()`) are now emitted as `-maxrate`/`-bufsize` alongside the existing `-crf`/`-preset`, giving the quality-driven encode a hard VBV ceiling so a rung's encoded bitrate never exceeds its advertised HLS `BANDWIDTH`. No bare `-b:v` is ever set — it would disable CRF mode; the cap is the `-maxrate`/`-bufsize` pair only, and it is emitted only when the caller supplies both keys. `-force_key_frames`, `-output_ts_offset`, and `-muxdelay`/`-muxpreload` stay byte-identical across every transcoded rung (only scale/bitrate/level differ), so ABR switching between rungs at a segment boundary stays seamless. - **Genuine stream-copy passthrough for "Original."** `video_codec === 'copy'` now emits a real `-c:v copy` (previously silently upgraded to a forced `libx264` re-encode) and skips `-force_key_frames`/scale/`-preset`/`-crf`/`-maxrate`/`-bufsize` — a stream copy can't force an arbitrary keyframe, so a copy segment's actual start may drift up to one source GOP length from the nominal boundary; acceptable for a manually-pinned Original variant but exactly why copy is never used for the ABR-switching rungs. `audio_codec === 'copy'` gets the same treatment independently (`-c:a copy`, no `-b:a`/`-ac`), so a mixed video-copy/audio-reencode segment (or the reverse) is fully supported. - Fully backward compatible: a caller that never passes `maxrate`/`bufsize`/`video_codec: 'copy'`/`audio_codec: 'copy'` gets the exact pre-A4 CRF-only command. - **ABR ladder builder + rendition value objects (Stream Quality/ABR step A2) — pure groundwork for the multi-variant HLS master at the time; wired into `TranscodeManager`'s master/media-playlist generation by step A5 (see above).** New `src/Media/Streaming/{AbrLadder,Rendition,SourceProfile,LadderResult}.php`. `AbrLadder::build(SourceProfile $source, string $profileName = 'generic'): LadderResult` is pure and deterministic — no DB, ffprobe, filesystem, clock, or randomness; identical inputs always produce identical output — and returns an ordered, source-clamped H.264 quality ladder (240p…2160p, highest-first) plus an "Original" descriptor, given the source's video/audio characteristics (`SourceProfile`, adaptable from A1's persisted `metadata_json['source']` via `SourceProfile::fromSourceMetadata()`, or constructed directly from a live probe) and a device-profile name looked up in the existing `QualitySelector` (`generic`/`mobile-low`/`mobile-high`/`web`/`tv-4k`). The ladder **consumes** `QualitySelector`'s `max_resolution`/`max_bitrate` device caps as its clamp ceiling — it does not replace or change `QualitySelector` itself, which still governs direct-play-vs-transcode selection. Every rung is clamped so it never upscales past the source resolution, never exceeds the source's own video bitrate when known, and never exceeds the device profile's resolution/bandwidth cap (reserving a 128 kbps AAC allowance + maxrate headroom so the advertised `BANDWIDTH` never exceeds the profile's cap); a rung's width is derived from the source's own aspect ratio (not a fixed 16:9), so anamorphic/DCI/ultrawide sources aren't distorted; a source below the lowest tier (or one squeezed below it by a narrow device-profile width) still yields exactly one clamped rung; unknown source dimensions cap the ladder conservatively at a 1080p 16:9 tier (never 1440p/2160p) and suppress the copy `Original`. Each transcode rung also carries the derived encoder targets step A4 will consume: `-b:v` (`videoBitrate`), `-maxrate` (`Rendition::maxrate()`, ≈1.07× target) and `-bufsize` (`Rendition::bufsize()`, 2×maxrate). `Rendition` mirrors the eventual wire shape `{id,label,width,height,bitrate,codecs,url}` (plan §1 D6) — `url` stays `null` here; step A5 wires the ladder into the transcode pipeline but still leaves `url` `null` (variant playlists are addressed by convention, `media_v{id}.m3u8`); step A7 (see above) is what actually fills `url` in, in a derived array copy of the ladder returned by `TranscodeManager::getJobVariants()` for the API response shape — `Rendition::toArray()` itself still always emits `url: null`. - **"Original" (D4): a stream-copy passthrough when the source is HLS-safe (H.264 + AAC) and fits the profile cap — `-c copy`, near-zero CPU, labelled `Original (<source height>p)` — else the top clamped transcode rung, relabelled `Original (best available)`, so the UI's "Original" choice doesn't map onto a duplicate master variant.** `LadderResult::streamVariants()` prepends the copy Original as a genuine extra highest variant when it applies, and omits the non-copy one so `A5` doesn't emit a duplicate `#EXT-X-STREAM-INF`. - **H.264 `CODECS` level is chosen by macroblock count (`ceil(w/16) * ceil(h/16)`) against a MaxFS table, not by height alone**, so a rung or copy `Original` whose frame is wider than its height tier's canonical 16:9 shape (2048×1080 DCI-2K, 2560×1080 ultrawide, 2560×1440, 3840×2160, …) advertises a legal, never-under-declared `avc1.*` level — e.g. plain 1920×1080 is L4.1 (`avc1.640029`), but 2048×1080 needs L4.2 (`avc1.64002A`) and 2560×1080 needs L5.0 (`avc1.640032`). **Coordination note for step A4:** the FFmpeg encoder must encode each rung — and the copy path — at this exact per-rung, macroblock-derived level, not a single flat `-level 4.1` applied to every rung, or the encoded stream will silently mismatch the level the master playlist advertises for wide/anamorphic tiers. ### Added - **Schema: `transcode_jobs.variants` column for the multi-variant ABR ladder (Stream Quality/ABR step A3) — schema-only groundwork, no behavior change yet.** New migration `migrations/049_transcode_jobs_variants.sql` adds a single nullable `variants TEXT` column (`AFTER segment_params`, same additive/idempotent `ALTER TABLE ... ADD COLUMN` style as `047_transcode_jobs_ondemand_columns.sql` — a re-run duplicate-column error is downgraded to a note by `MigrationRunner`). It will carry the resolved ABR ladder as JSON-shaped text (matching `segment_params`'s TEXT convention, since the workerman/mysql driver handles it as a plain string rather than native JSON): `LadderResult::toArray()` from step A2 (`src/Media/Streaming/{AbrLadder,Rendition,LadderResult}.php`) — `{renditions: [Rendition::toArray(), ...highest-first], original: Rendition::toArray()}`, each rendition flat as `{id, label, width, height, bitrate, codecs, url, is_original, is_copy, video_bitrate}`. Nothing read or wrote the column at the time — step A5 (see above) is what builds the ladder in `TranscodeManager::ensureHlsJob()` and persists it here, so the master/media playlists are rebuilt without recomputing the ladder from a live `ffprobe` on every request. Existing rows are unaffected: `variants` is `NULL` for every pre-A5 job, and the pipeline keeps working unmodified via the existing single-variant columns (`profile`, `segment_params`, …) as the fallback path. ### Added - **Scanner now persists source video/audio technical metadata at scan time (Stream Quality/ABR step A1) — groundwork for the upcoming multi-quality (ABR) streaming ladder.** The single `ffprobe` call the scanner already ran per time-based file (video/movie/episode/audio) now also derives a compact technical summary, stored at `metadata_json['source'] = {width, height, video_codec, video_bitrate, pix_fmt, audio_codec, audio_bitrate}`, so a later ABR ladder builder can pick renditions without re-probing on every playback start. The existing total-duration probe is folded into the same call (no added probing cost). The primary video stream is chosen skipping embedded cover-art / poster streams (`disposition.attached_pic = 1`, common in MKV/MP4/M4V), so a poster's tiny dimensions never masquerade as the source resolution; `video_bitrate` falls back to the whole-file `format.bit_rate` when the video stream itself reports none (common for Matroska). Runs on both the initial-scan and the incremental-rescan path — the rescan path (previously a duration-only backfill) now backfills `source` and the streams below too, for files indexed before this change. - **`media_streams` rows (video + primary audio) are now written from the same probe**, via the existing `ItemRepository::addStream()` and a new `ItemRepository::deleteStreamsByItem()`. Replacement is delete-then-insert (idempotent) because the table carries no unique key on `(media_item_id, stream_index)` and would otherwise duplicate rows on every rescan. - **New offline CLI `scripts/backfill-source-metadata.php [--library=<id>] [--limit=<n>]`** populates `metadata_json['source']` (+ duration + streams) for items indexed before this change. Idempotent and guarded per item: an item already carrying both a positive duration and a `source` blob is skipped without probing, a probe or write failure on one item is logged and never aborts the run, and the process exits non-zero when any item hard-fails so automation can detect a partial run — failed items are left `source`-less and are picked up again on the next invocation. ### Fixed - **Transcoded playback (MKV / HEVC / 10-bit) now reports the real length and seeks anywhere.** A file the browser can't direct-play (e.g. a 10-bit HEVC `.mkv`) is transcoded to HLS on demand. The pipeline previously ran a **single linear CMAF encode** (`ffmpeg -f dash … -hls_playlist 1`) that wrote HLS child playlists **incrementally with no `EXT-X-ENDLIST` / `PLAYLIST-TYPE:VOD`** until the whole (minutes-long, software) encode finished. hls.js therefore treated the stream as **live**: `video.duration` only grew as segments arrived, and `seekable` covered only the encoded-so-far region, so seeking past it snapped back. (The dead `playlist_type => 'vod'` param was only read by the unused `-f hls` path, never the `-f dash` path that actually ran.) - **`TranscodeManager::ensureHlsJob()` is now on-demand + seek-aware.** On job creation it probes the duration and publishes a **complete VOD playlist immediately** — `master.m3u8` + `media_0.m3u8` listing every segment with its `EXTINF`, `#EXT-X-PLAYLIST-TYPE:VOD`, and a closing `#EXT-X-ENDLIST` — so the player knows the true total length and full seekable range up front. No background A/V encode is launched (subtitle extraction still runs detached). The job is recorded `status='completed'` so the stale-job reaper (which only reaps `running` rows) can't tear it down mid-watch. - **Segments transcode on demand.** `HlsController` routes a `seg-NNNNN.ts` request through the new `TranscodeManager::ensureSegment()`, which returns a cached segment or launches `FfmpegRunner::startSegmentEncode()` — an `-ss` fast-seek encode of exactly that segment's window, with a forced keyframe at its start and `-output_ts_offset` anchoring its PTS to the timeline (so segments stitch and a seek lands correctly). The request polls for the atomically-renamed segment with a **coroutine-yielding `usleep`** (`SWOOLE_HOOK_SLEEP` is in the curated hook set) so a waiting request never blocks the worker. Any segment — including one far past what has been produced — is served, so the user can seek anywhere (a ~1–3 s spin-up per uncached seek on CPU). A per-job soft cap bounds concurrent encodes so frantic scrubbing can't fork-bomb ffmpeg. - **Migration `047_transcode_jobs_ondemand_columns.sql`** — adds `duration_seconds`, `segment_seconds`, and `segment_params` (JSON) to `transcode_jobs` so any segment can be built later without re-probing. Reuse now requires `segment_params IS NOT NULL`, so a legacy linear-CMAF job left in the table across the upgrade is **not** reused (its live playlist is skipped; a fresh on-demand job is created). DASH manifest generation is dropped from this path — no client consumed it (web, mobile, and Roku all play the HLS `master_url`). No client change is needed: the SPA already detects `.mkv`/HEVC and starts the transcode; it now attaches to a proper seekable VOD stream. - **Metrics: admin `/api/v1/admin/metrics/*` no longer 500s with a "Couldn't execute method `Error::__toString`" fatal.** The S2 metrics wiring registered the concrete `MetricsRepository` but never bound the read-side `MetricsRepositoryInterface`, while `MetricsController` type-hints the interface and `AdminRoutes` resolves `get(MetricsController::class)` at route registration. PHP-DI then tried to instantiate the interface directly and threw `InvalidDefinition` ("the class is not instantiable"), which the Workerman error handler surfaced as the mangled `Error::__toString` fatal. `MetricsServicesProvider` now aliases `MetricsRepositoryInterface::class => get(MetricsRepository::class)` (reusing the shared concrete singleton); a new regression test asserts the interface resolves. ### Security - **systemd unit: extra kernel/privilege hardening (phlix-hub parity).** Adds `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectControlGroups`, `ProtectHostname`, `ProtectClock`, `RestrictSUIDSGID`, and `RestrictRealtime` to the generated `[Service]` block, on top of the existing `ProtectSystem=strict`/`ProtectHome`/`NoNewPrivileges`/`PrivateTmp`/`RestrictNamespaces`/`LockPersonality`/`RemoveIPC` set. All are safe for the media server (software transcoding shells out to ffmpeg; optional DVB/DLNA needs neither module loading nor clock/hostname/cgroup writes). Deliberately **not** setting `PrivateDevices` (would hide `/dev/dvb` tuners and `/dev/dri`), `MemoryDenyWriteExecute` (breaks PHP JIT/opcache), or `SystemCallFilter` (Swoole io_uring is syscall-sensitive). Verified with `systemd-analyze verify` and a `systemd-run` sandbox on the host. ### Added - **Multi-level "Love" for media items (Feature 10).** Builds on the per-user favorites/ratings (E10, below) with a separate 0-3 "Love" axis distinct from `favorite` (boolean) and `rating` (1-10). - **Migration `044_user_item_like_level.sql`** — adds `like_level TINYINT NOT NULL DEFAULT 0` to `user_item_data` (`AFTER rating`). `0` = not loved … `3` = most loved. The 0-3 range is enforced in PHP (`UserItemDataRepository::setLikeLevel()` throws `InvalidArgumentException` out of range) — **not** via a DB `CHECK` constraint (consistent with mig 039's PHP-enforced 1-10 rating). Idempotent re-run via the runner's "Duplicate column name" downgrade. **This migration is the W1 deploy trigger (server migration max → 044).** - **New endpoint `PUT /api/v1/media/{id}/like`** (body `{level: int 0-3}`, required) → `{message: "Love level saved"}`; `400` on missing/non-integer/out-of-range `level`, `401` unauthenticated, `404` unknown item. Handled by `MediaUserDataController::setLikeLevel()`, validated/coerced exactly like `setRating` (the range is deferred to the repository). Registered once in the `requireAuth` group on `WebPortalRouter` (with a 503-when-unwired delegate) — reachable from **both** HTTP entry points (CGI/web-portal + the Workerman daemon's `HttpHandler`, which forwards `/api/*` 404s to `WebPortalRouter`), exactly as the existing favorite/rating routes do. - **`UserItemDataRepository::setLikeLevel(userId, itemId, level)`** — upserts via `INSERT ... ON DUPLICATE KEY UPDATE` (flat positional `?` binding, colon-free), preserving `favorite`/`rating`; `getItemData()` and `getFavorites()` now also select `like_level` (int, default 0 when absent/NULL). - **`user_data` block extended (add-only).** `GET /api/v1/media/{id}` and `GET /api/v1/users/me/favorites` now carry `like_level: int` (default 0) alongside `favorite`/`rating`. No existing key disturbed; only detail + favorites-list responses build a `user_data` block (browse/list rows do not). - **Strip multi-word "noise" suffixes from match titles (Feature 13).** Filename→title cleaning now peels trailing edition/scene markers — `Directors Cut`, `UNCUT & UNRATED`, `ALTERNATE ENDING`, `Extended Cut`, `Remastered`, `YIFY`, `DC`, … — before the title is sent to a metadata provider, so they no longer depress the match hit-rate. New shared `Phlix\Media\Metadata\TitleSuffixStripper` is the single source of truth (longest-phrase-first, end-anchored, word-boundary; a single-token noise word never empties a title; the original filename `raw` is never mutated); both the movie normalizer (`SceneFilenameNormalizer`) and the series parser (`EpisodeFilenameParser::cleanSeries()`) consume it. The effective list is admin-extensible via the new `matching.noise_suffixes` server-setting (replace-not-merge override; an empty override falls back to the code defaults mirrored in `config/matching.php`), wired through `MediaServicesProvider`. - **Auto-merge / de-duplicate top-level series & movies (Feature 1).** A title-slug variance (separators, year bleed, a flat→per-directory re-scan, a concurrent-scan race) used to silently create a second top-level row for the same title — the "100 episodes + 1 stray episode" symptom — because there is no DB UNIQUE constraint on items. Added: - **`Phlix\Media\Library\CanonicalKey`** — a pure normalizer that collapses separator/article/case variance and prefers a matched external id (`imdb:` > `tmdb:` > `title-key:year` > `title-key`). - **Prevention at scan time** — `MediaScanner` now resolves a container in the tier order `containerCache → exact path (findByPath) → canonical key (findTopLevelByCanonical)`, reusing an existing container on a canonical hit rather than manufacturing a sibling. Applied to the top-level movie create path too. - **`DuplicateFinder`** — pages a library's top-level items in fixed batches, buckets them by canonical key + type, returns groups of size ≥ 2 with the most-descendants member as the primary. - **`SeriesMerger`** — `merge(primaryId, duplicateIds): {moved, deleted}`; for a series, re-parents episodes onto the primary's matching season (re-parent-before-delete) then deletes empty shells; for a movie, gap-fills missing metadata (add-only) then deletes the duplicate. Runs inside one real DB transaction; works under both `DB_POOL_ENABLED=0` and `=1` (the ctor takes the base `Workerman\MySQL\Connection`). Per-user playback markers are out of scope: re-parented episodes keep their ids (state survives); deleted shells/duplicate-movie rows lose their own per-user rows via `ON DELETE CASCADE`. - **Migration `043_media_items_canonical_key.sql`** — adds a nullable, **non-unique** `canonical_key VARCHAR(191)` column + `(library_id, type, canonical_key)` index (no UNIQUE — historical dupes exist; uniqueness is enforced in app code). `ItemRepository::create()/update()` write the column as the source of truth. - **`scripts/dedup-series.php [--library=ID] [--dry-run|--apply]`** — offline backfill that runs `DuplicateFinder` per library and, on `--apply`, calls `SeriesMerger`; dry-run is the default and a re-run after `--apply` reports zero groups (idempotent). - **Admin merge API** — `GET /api/v1/admin/libraries/{id}/duplicates` (preview groups) and `POST /api/v1/admin/media/merge` `{primary_id, duplicate_ids[]}` → `{moved, deleted}`, both admin-gated (`Phlix\Server\Http\Controllers\Admin\AdminMergeController`, registered via `AdminRoutes::register()` for both entry points). Validates same-library + same-type, rejects self-merge (`400`), `404` on missing primary, `503` when no transaction-capable connection is bound. - **Metadata source priority — configurable per-field fallback (Feature 3).** The matching pipeline now normalizes each provider's payload into a canonical field set and resolves each field by walking a configurable source order, taking the first non-empty value (external IDs merged, earlier source wins). Added `Phlix\Media\Metadata\Resolution\{SourceRecord, FieldMappers, PriorityFieldResolver, PriorityConfig}`. The order is driven by the new `metadata.provider_priority` server-setting (per media type; defaults `movie`/`series` = `["tmdb","imdb"]`, `anime` = `["anidb","myanimelist","tvdb","fanart","local"]`) and `metadata.genres_mode` (`first` | `union`, default `first`); defaults live in `config/metadata.php`. `MovieMetadataResolver`/`SeriesMetadataResolver` were refactored onto this resolver behavior-preservingly under the default order (the live series resolver keeps a fixed `['tmdb']` order — making the configured series order take effect is a deliberate future change). - **Admin metadata-source name endpoint (`GET /api/v1/admin/metadata/sources`, Step 3.6).** New admin-gated, read-only JSON endpoint returning `{sources: string[]}` — the available metadata-source names so the admin SPA's per-media-type priority editor (`metadata.provider_priority`) can list REAL names. The list is the built-in sources (`tmdb`, `imdb`, `tvdb`, `fanart`, `local`, in that stable order) followed by any plugin sources currently registered in `SourceRegistry` (e.g. `anidb` / `myanimelist` when those plugins are enabled), de-duplicated. Handled by new `Phlix\Server\Http\Controllers\Admin\AdminMetadataSourceController` (its only dependency is the container-scoped `SourceRegistry`); registered via `AdminRoutes::register()` so BOTH HTTP entry points (the Workerman daemon `Application` + the `public/index.php` web portal) expose it from one registration, gated by `AdminMiddleware` (401 unauth / 403 non-admin). No DB access, no write. - **First-class metadata-source plugin contract (`SourceRegistry`, Step 3.5b).** Plugins that provide metadata (e.g. `phlix-plugin-anidb`, `phlix-plugin-myanimelist`) can now register through the shared typed contract `Phlix\Shared\Metadata\MetadataSourceInterface` (shipped in `detain/phlix-shared` v0.15.0) instead of the old brittle `method_exists($manager,'registerProvider')` / FQCN container-sniffing dance. New `Phlix\Media\Metadata\Resolution\SourceRegistry` is a process-scoped registry of `MetadataSourceInterface` instances keyed by `sourceName()`; it is wired as a single container-scoped binding in `MediaServicesProvider`. `PluginLoader::enable()` now `register()`s any enabled plugin entry instance that implements `MetadataSourceInterface`, and `PluginLoader::disable()` `deregister()`s it — a leak-free enable/disable cycle (re-register is idempotent, deregister truly removes). Bumped the `detain/phlix-shared` constraint `^0.14.0` → `^0.15.0`. This step builds the registry + plugin contract only; it does **not** change `MovieMetadataResolver`/`SeriesMetadataResolver` output (the live series resolver keeps its fixed `['tmdb']` order from Step 3.4) — feeding the registry into the resolvers is a later step. - **`web-ui`: bumped `@phlix/ui` `v0.56.0` → `v0.57.0` and rebuilt the committed SPA bundle (`public/assets/app/`) (Wave 1 bump).** Picks up the favorites wiring (`MediaCard` favorite button, Browse "Favorites" row, Browse/Detail persistence + hydrate), the multi-level **Love** control (`LoveButton.vue` 4-state component on cards + detail), and the player favorite/Love controls (`Player.vue`, `MiniPlayer` favorite toggle, `PlayerPage` hydrate). `package.json`/`package-lock.json` pin the new `v0.57.0` git tag; the Vite bundle was regenerated (now includes `LoveButton-*`). No server PHP changed for the bump. - **`web-ui`: bumped `@phlix/ui` `v0.55.0` → `v0.56.0` and rebuilt the committed SPA bundle (`public/assets/app/`) (Wave 0 bump).** Picks up the shared admin **Duplicates** page (the UI for the merge API above) and the **Metadata** settings tab's per-media-type source-priority editor. `package.json`/`package-lock.json` pin the new git tag; the Vite bundle was regenerated. No server PHP changed for the bump. - **Per-user favorites + ratings for media items (E10).** Each user can now mark any media item as a favorite and give it a personal 1-10 rating, persisted server-side. New table `user_item_data(user_id, item_id PK, favorite BOOL, rating INT NULL, updated_at)` (`migrations/039_user_item_data.sql`, FK CASCADE → `users` + `media_items`) and `Phlix\Media\UserItemDataRepository` (flat positional-`?` binding like `UserRepository`/`WatchHistory`; upserts with `INSERT ... ON DUPLICATE KEY UPDATE` per-column so favorite/rating preserve each other; the 1-10 range is enforced in PHP — `setRating()` throws — not via a DB CHECK). Four auth-gated routes on `WebPortalRouter` (handled by `MediaUserDataController`): `POST /api/v1/media/{id}/favorite`, `DELETE /api/v1/media/{id}/favorite`, `PUT /api/v1/media/{id}/rating` (body `{rating: int 1-10|null}`; 400 on non-numeric/out-of-range, 404 if the item is missing), and `DELETE /api/v1/media/{id}/rating` — all return `{message}`. `GET /api/v1/media/{id}` now also carries an **add-only** `user_data: {favorite: bool, rating: int|null}` block (`null` when unauthenticated; `{favorite:false, rating:null}` when authed with no row), leaving every existing detail key untouched. **Favorites/ratings are account-level (keyed on `user_id`, like `user_settings`) — NOT per-profile** like `watch_history`. Registered once on `WebPortalRouter` (not `Application::loadApiRoutes()`) so all three dispatch paths — `public/index.php`, the Workerman daemon's `HttpHandler`, and the relay dispatcher — share it. Existing items have no row until first interacted with, so they simply report `favorite:false, rating:null`; no backfill needed. ### Fixed - **Docs: `AdminMergeController` docblock corrected.** The merge controller's docblock described the `503` ("merge unavailable") trigger as "the active DB connection is not the transaction-aware `PhlixMySQLConnection`", which was stale after the `SeriesMerger` ctor was widened to the base `Workerman\MySQL\Connection`. It now reads "no transaction-capable base `Connection` bound", reflecting that the merger is wired from whichever connection ships — `PhlixMySQLConnection` (`DB_POOL_ENABLED=0`) or `PooledMySQLConnection` (`DB_POOL_ENABLED=1`). Comment-only change. - **Hub config is now actually loaded (fixes empty hostname candidates → blank server URL / disabled "Manage").** `config/hub.php` was never wired into the app config, so `HubServicesProvider` fell back to bare defaults — notably `public_url = ''` — and the server advertised no hostname candidates at pairing (`hostname_candidates_json: []`), leaving the hub with no URL for the server and the "Manage" button disabled. `config/server.php` now `require`s `config/hub.php` as its `hub` key (like `ffmpeg`), so the configured `public_url` (`https://<PHLIX_DOMAIN>`) reaches `HubClient`. The hub updates `hostname_candidates_json` from every heartbeat, so existing pairings self-heal on the next heartbeat (no re-pair needed). - **Manual "Send heartbeat" (admin) no longer fails with cURL "No host part in the URL".** `HubClient::sendHeartbeat()` posts to a relative path via `$this->httpClient`; the heartbeat *loop* swaps that for an enrollment-scoped client, but a direct admin call runs in the HTTP worker where the loop never ran, so it used the empty-base placeholder. `sendHeartbeat()` now (re)builds an enrollment-scoped client from the freshly-loaded enrollment (also picking up a renewed token), leaving test-injected mocks untouched. - **Hub pairing now completes reliably (server stores enrollment + heartbeat starts without a restart).** Two bugs left a paired server stuck at "not paired / last seen never": (1) the admin SPA's poll→complete flow sent an empty `hubJwksUrl` to `POST /hub/complete`, which required it and returned 400 — so the enrollment was never stored, and because the hub deletes the one-time claim the instant it returns the JWT, re-polling couldn't recover; (2) even once stored, `HubApplication::start()` is one-shot, so the heartbeat loop wouldn't run until a process restart. Now: `hubPoll()` stores the enrollment **server-side** the moment the claim is consumed (atomic, client-independent) and returns `hubJwksUrl`; `hubComplete()` is **idempotent** (returns success when already enrolled instead of 400 on a missing field); and the `phlix-hub-heartbeat` worker **polls for a late-appearing enrollment** (`HubApplication::isEnrolled()`) and starts the heartbeat within ~15s of pairing — no restart needed. - **Hub pairing now advertises the server's configured public URL.** Under the Workerman daemon `$_SERVER` is empty, so `HubClient::getHostnameCandidates()` reported `[]` and the hub recorded no reachable hostname for the server (blank URL in "My Servers"). The server now advertises its public base URL — derived from `PHLIX_DOMAIN` (set by `scripts/install.sh --domain`) with the scheme from `tls_enabled` — as the preferred hostname candidate during pairing (`config/hub.php` `public_url` → `HubClient`). `install.sh` also now persists `PHLIX_TLS_ENABLED` (from `--tls`/`--no-tls`) so the scheme is correct, and fixes a latent bug where `PHLIX_TLS_ENABLED=0` was ignored (`?: true` treated `'0'` as unset). - **`PhlixMySQLConnection`: force emulated + buffered prepared statements.** Under the Swoole event loop mysqlnd's socket is coroutine-hooked, so a query yields the coroutine while waiting on the socket. With the parent's **native, unbuffered** prepares each statement keeps per-statement server-side state on that socket which leaks across coroutine yields — wedging the shared connection so the next `prepare()` silently returns `false` (`Call to a member function bindParam() on false`) or params desync (`HY093`) under concurrent requests, even with the per-coroutine query mutex. `connect()` now sets `PDO::ATTR_EMULATE_PREPARES = true` (prepare is client-side only) and `PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true` (results consumed immediately). Because emulated prepares would otherwise bind params as strings (making `LIMIT ?`/`OFFSET ?` → `LIMIT '50'` → MySQL 1064, and this codebase has many such queries), `execute()` is also overridden to bind each value with its natural PDO type (`int → PARAM_INT`, …), mirroring the parent's prepare/execute + 2006/2013 reconnect. Diagnosed + verified first on phlix-hub (150 concurrent requests → zero corruption; bound/positional/mixed `LIMIT` queries succeed); applied here for parity since this connection class runs the same native-prepare path under the same coroutine runtime. Parameterisation stays injection-safe; charset is utf8mb4. ### Changed - **`web-ui`: bumped `@phlix/ui` `v0.43.0` → `v0.44.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Carries the **required companion** to the signed-URL gate (see Security below): the web player now points its direct-play `<video src>` at the server-minted **signed** `stream_url` from `GET /api/v1/media/:id` instead of building a bare `/media/:id/stream` path. Without this bump direct play would `401` once the gate deploys (the SPA holds a `localStorage` token, not a session cookie, and a media element can't attach a `Bearer` header). The hls.js/transcode path already attaches the Bearer token to every segment XHR via `xhrSetup` and is unaffected. **Must deploy together with the gate.** No server application code changed for the bump — `web-ui/package.json`/lockfile pin the new git tag and the Vite bundle was regenerated. - **`web-ui`: bumped `@phlix/ui` `v0.42.0` → `v0.43.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Fixes the library grid where the same titles could "stay" on screen while scrolling: the virtual grid measured the scroll offset on a `requestAnimationFrame`-deferred path that stalls under scroll load (rAF is throttled during scrolling, notably on Firefox), freezing the rendered window. The grid now measures synchronously on scroll. No server application code changed — `web-ui/package.json`/lockfile pin the new git tag and the Vite bundle was regenerated. - **`web-ui`: bumped `@phlix/ui` `v0.41.0` → `v0.42.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Fixes the A-Z jump rail showing **empty skeleton boxes**: the pre-sized library grid only paged sequentially (append-at-end), so jumping to a letter scrolled to slots whose page was never fetched. The grid now does random-access paging — it loads the page(s) covering the visible window (including after a jump) and places items at their absolute index. No server application code changed — `web-ui/package.json`/lockfile pin the new git tag and the Vite bundle was regenerated. - **`web-ui`: bumped `@phlix/ui` `v0.40.0` → `v0.41.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Carries the SPA fix that makes every API client send the logged-in user's Bearer token by default (previously the default token store was a no-op). This is the **required companion** to the media-auth gate above: the SPA now authenticates media/letter-index requests, so requiring auth on those endpoints doesn't break browsing. Must deploy together with the gate (both land on master before `install.sh --update`). No server application code changed for the bump — `web-ui/package.json`/lockfile pin the new git tag and the Vite bundle was regenerated. - **Media listings now sort & bucket by an article-stripped title — "The Plot" files under P, not T (the title still displays as "The Plot").** Alphabetical browse/library listings and the A-Z jump rail previously ordered and grouped by the raw `name`, so every "The …"/"A …"/"An …" title piled up under T/A. A new `Phlix\Media\Library\SortTitle` derives a sort key by ignoring a single leading article — English `the`/`a`/`an` plus the common Romance/German articles `el`/`la`/`le`/`les`/`los`/`las`/`die`/`der`/`das` (only when the article is a whole word, so "Theory", "Antman" and "Death Race" keep their natural letter). `ItemRepository` applies it to every alphabetical `ORDER BY` (the `/api/v1/media` name sort + its tiebreaks, plus `getByLibrary`/`getByType`/`findByParent`/the rating- & genre-filtered listings) and to the `letter-index` bucket `GROUP BY`, so the rail's cumulative offsets still line up with the grid. The expression is a single source of truth that also powers a new **`sort_title`** field on the media-item API shape (so any client-side sort can agree), and it is **portable + zero-migration**: it uses only `CASE`/`LOWER`/`LEFT`/`SUBSTRING`/`TRIM` with a `COLLATE utf8mb4_bin` prefix test (case-insensitive but accent-sensitive, mirroring PHP `strncasecmp`) — never `REGEXP_REPLACE`, whose case-insensitive form differs between MySQL 8 and MariaDB 10.6 — so it is correct for every existing and future row the instant it deploys, with no schema change or backfill. `date_added`/`year`/`rating`/`runtime` sorts keep their primary key and only gain the article-insensitive alphabetical tiebreak. (Known pre-existing limitation, unchanged here: a title whose first significant letter is accented or non-Latin — e.g. "Élan" — still folds into the rail's `#` bucket while sorting in its locale position, so the rail jump can be slightly off for such titles in multilingual libraries.) - **`web-ui`: bumped `@phlix/ui` `v0.39.0` → `v0.40.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Carries the client side of the article-aware sorting above: the `MediaItem.sort_title` field, a `stripLeadingArticle`/`compareByStrippedTitle` helper, and library Browse rails that sort by the article-stripped name (so a "The …"-named library files under its real letter, matching the media listings). The media grid + A-Z rail already reflect the article rule because they render the server's order; this bump keeps the client-sorted surfaces consistent. No server application code changed for the bump — `package.json`/`package-lock.json` pin the new git tag and the Vite bundle was regenerated. - **`web-ui`: bumped `@phlix/ui` `v0.32.0` → `v0.36.0` and rebuilt the committed SPA bundle (`public/assets/app/`).** Brings the shipped UI work to the served app: **full-width layout** + **clicking a poster opens the info/detail page** (v0.33.0); the **matched/unmatched metadata filter** + **clickable cast** (each cast name opens that title's library filtered to the actor) (v0.34.0); the listing grid **pre-sized to the full result count** with on-demand paging (v0.35.0); and the **A-Z jump rail** on long library listings (v0.36.0, driven by the new `letter-index` endpoint). No server application code changed for the bump — `package.json`/`package-lock.json` pin the new git tag and the Vite bundle was regenerated. ### Security - **Binary / streaming / feed endpoints now require proof of an authenticated session via short-lived signed URLs (closes the residual `TODO(security)` gap from the media-auth gate below).** The byte-serving routes a `<video>`/`<img>`/`<audio>`/e-reader/native player can't attach a Bearer header to — `/media/{id}/stream`, `/hls/**`, `/dash/**`, `/api/v1/books/{id}/{read,cover,download}`, `/opds/v1.2/**`, `/api/v1/audiobooks/{id}/{read,stream}`, `/api/v1/photo/photos/{id}/{thumbnail,full}` — were reachable by anyone who knew the (UUID) id. They are now gated: - **`Phlix\Auth\SignedUrl`** mints/verifies a `?exp=<unix>&sig=<base64url-HMAC-SHA256>` token. The HMAC covers a *canonical resource* (the query-less path) + expiry; for HLS/DASH the resource is the per-job directory prefix (`/hls/{job}`, `/dash/{job}`), so ONE signature on the master-playlist URL authorises every variant playlist, segment and sidecar under it. The signing key comes from `PHLIX_SIGNED_URL_SECRET`, or is derived (domain-separated) from `JWT_SECRET` when unset — so a leaked stream token can't be replayed as a JWT, and vice-versa. Default TTL is 6 h, overridable via `PHLIX_SIGNED_URL_TTL`. - **`Phlix\Server\Http\Middleware\SignedUrlMiddleware`** gates those route groups (and an inline equivalent guards `/media/{id}/stream`, which bypasses the router for Workerman `withFile` streaming). It accepts **any** of: an already-authenticated session (`$request->userId` from Bearer **or** the `phlix_session` cookie — so the in-browser player keeps working untouched: hls.js attaches the Bearer token to every segment XHR via `xhrSetup`, and same-origin `<img>`/`<video>` send the cookie automatically), a valid signed-URL token (cookieless/headerless native players, casting, cross-origin), or — for OPDS only — HTTP Basic. Anonymous requests get `401 {code: auth.required}`. - **OPDS feeds now support HTTP Basic auth**, because e-reader clients send `Authorization: Basic` (not Bearer) and re-send it on every request. A new session-free `AuthManager::verifyCredentials()` validates the credentials of an *active* account without minting a session; failures return `401` with `WWW-Authenticate: Basic realm="Phlix OPDS"` so the reader prompts. The acquisition `download` link the feed already emitted (`/opds/v1.2/books/{id}/download`) is now actually registered (it was a dead 404), so the whole OPDS flow is authenticated **and** functional. - **Minting:** the now-gated JSON detail / transcode endpoints emit the signed URLs in the fields clients already read — `GET /api/v1/media/{id}` (`stream_url`), `getBook`/`readBook` (`cover_url`/`read_url`/`download_url`), `getAudiobook`/`readAudiobook` (`stream_url`/`read_url`), `getPhoto`/`listPhotos`/`getAlbum`/`listAlbums`/`slideshow` (`thumbnail_url`/`full_url`), and `POST /api/v1/media/{id}/transcode` + `…/status` (signed `master_url`/`hls_url`/`dash_url` and signed subtitle track URLs). The photo `slideshow` URLs were also corrected to the real `/api/v1/photo/photos/…` route (they previously pointed at an unregistered `/photo/photos/…` path). - The existing JSON listing/search/detail gate (`AuthMiddleware`, below) is **unchanged**. Companion client work lands separately: `@phlix/ui` (the web player consumes the signed `stream_url` and keeps hls.js `xhrSetup`) and the native Roku/Tizen/Windows/mobile clients (consume the server-provided signed URL instead of a bare path). - **Media & library listing/search endpoints now require a signed-in user (were world-readable).** `GET /api/v1/media` (the SPA's main browse/search), `/api/v1/media/letter-index`, `/api/v1/media/{id}`, `/api/v1/media/{id}/playback`, `/api/v1/libraries{,/{id},/{id}/items}`, the `/api/v1/users/me/*` activity & settings routes, the per-item markers/extras metadata, and the music / books / audiobooks / photos **JSON listing + detail** endpoints had **no auth gate**, so anyone could enumerate a user's entire private library without logging in. A new dependency-free `Phlix\Server\Http\Middleware\AuthMiddleware` (the authenticated-but-not-necessarily-admin counterpart to `AdminMiddleware`) now gates these route groups in BOTH dispatch paths (`WebPortalRouter` for `public/index.php` + the Workerman `HttpHandler`); `$request->userId` is already populated from the Bearer token **or** the `phlix_session` cookie before dispatch, so logged-in SPA and browser-session requests pass while anonymous ones get `401 {code: auth.required}`. **Requires `@phlix/ui` ≥ v0.41.0** (the SPA now sends the token on media requests; older bundles would break browsing). Binary/streaming routes a `<video>`/`<img>`/e-reader can't attach a Bearer header to — `/media/{id}/stream`, `/hls/**`, `/dash/**`, book `cover`/`read`/`download` + OPDS feeds, audiobook `read`/`stream`, photo `thumbnail`/`full` — are **deliberately left open for now** (an item id is an unguessable UUID, only reachable via the now-gated listings) and flagged `TODO(security)` for a follow-up signed-URL / OPDS-Basic-auth pass. Native clients (Roku/Tizen/Windows/mobile) must send their access token on media requests. - **`web-ui`: bumped `@phlix/ui` to `v0.20.0` — the SPA now validates the session on boot and gates the admin section client-side.** The shared router guard previously treated a token's mere *presence* in `localStorage` as "logged in" (never validating it) and applied no admin-role check, so after a reload a stale/expired token would render every protected route — including the whole `/app/admin/*` console — and the account badge fell back to a generic "A" because the user was never rehydrated. v0.20.0 validates a restored token once via `/auth/me` before the first protected route resolves (clearing it + redirecting to login when invalid) and redirects a logged-in non-admin away from admin routes. The server API already authorized every request (`AdminMiddleware`), so this was client-side broken access control, not data exposure. The committed bundle under `public/assets/app/` was rebuilt; no application code changed. - **Require admin authentication on theme-media mutation endpoints.** `POST /api/v1/libraries/{id}/theme-media/scan` (`scanThemeMedia`) and `DELETE /api/v1/libraries/{id}/theme-media` (`deleteThemeMedia`) were registered as bare routes with no auth gate, so unauthenticated callers could trigger filesystem scans and delete cached theme media for any library ID. `ThemeMediaController` now carries an optional `AdminMiddleware` (wired in `Application::getThemeMediaController()`, mirroring `LibraryController`) and both mutation methods return `401`/`403` for unauthenticated/non-admin callers before any side effect. The read endpoint (`getThemeMedia`) is unchanged. (Flagged by an external review of an earlier PR; verified still present and fixed here.) ### Fixed - **"Initiate pairing" (admin → Remote Access) no longer 500s with "Failed to write Ed25519 private key: config/hub-server-key.pem".** The hub-pairing flow persists its runtime state — the server's Ed25519 private key plus `hub-enrollment.json` / `hub-subdomain.json` — into `config/`, but the systemd unit's `ProtectSystem=strict` mounts the install tree read-only **except** the paths in `ReadWritePaths` (which listed `var/`, `.logs`, `templates_c`, … but **not** `config/`), so the key write failed. `scripts/install.sh` now idempotently appends `${INSTALL_PATH}/config` to the unit's `ReadWritePaths` (mirroring the existing `var/` migration) and reloads systemd, so the daemon can persist the pairing key/state. Same root cause + fix shape as the earlier plugin-install `var/` read-only fix. (Applied live to the running unit; this change keeps it across reinstalls/regenerations.) - **"Initiate pairing" now reaches the hub (fixes "cURL error: URL rejected: No host part in the URL").** After the key-write fix above, pairing got further but then failed: `HubClient::initiatePairing()` / `pollClaimStatus()` passed the operator-entered hub URL only to the *logger*, then called the injected HTTP client with a bare **path** (`/api/v1/server-claims/new`). That injected client is an **empty-base placeholder** (`PHLIX_HUB_URL` is normally unset; the post-enrollment heartbeat/renew loop rebuilds the client from the saved enrollment, but pairing runs *before* enrollment), so cURL received a hostless URL. The two pre-enrollment calls now send the **absolute** `rtrim($hubUrl,'/') . '/api/v1/…'`, and `HttpClient` uses an already-absolute path verbatim (a bare path still resolves against the configured base, and an empty resulting URL now throws instead of reaching cURL). Pairing reaches the hub. Unit tests assert the absolute URL is sent. - **The "Install plugin" admin form now actually installs a plugin (fixes the silent no-op).** Even after the repo-URL fix, pasting a plugin's GitHub URL still failed: the source downloaded + extracted fine, but the plugin's `plugin.json` was rejected by the manifest schema with *"manifest is invalid: 9 errors"*. `detain/phlix-shared`'s settings schema used `additionalProperties: false` allowing only `type`/`required`/`secret`/`default`, so every real plugin's per-setting **`label`/`description`** (and `type: "boolean"`) failed validation — the install returned a 422 that the SPA surfaced as a generic failure, perceived as "nothing happening". Bumped `detain/phlix-shared` to **v0.9.1**, whose manifest schema permits per-setting `label`/`description` and accepts `integer`/`boolean` as aliases of `int`/`bool`. Verified the anidb + myanimelist manifests now validate against the vendored schema. (`install.sh --update` / `composer install` pulls the new schema; no application code changed.) - **Cast / actors now populate on movie & TV detail pages, and the actor filter actually matches (fixes empty cast lists).** TMDB cast was fetched but lost or mis-shaped before it reached the UI: the bulk "Match metadata" path (`MovieMetadataResolver::merge()`) dropped `actors` entirely, the interactive "apply match" path stored them as TMDB *objects* (`{name, role, order}`), and TV details never requested credits at all — while the API shaper, the SPA cast chips and the `actors[]` filter all expect a flat list of name strings (so cast lists rendered empty and "filter by actor" matched nothing). A shared `MetadataValue::actorNames()` reduces either shape to a de-duplicated list of names; both movie paths persist names, the TV path now appends `aggregate_credits` (`TmdbProvider::getTvDetails`) and carries the series cast (`formatSeriesDetails`), `MediaItemShaper` normalises on read (so already-stored object data renders without a re-match), and the `ItemRepository` actor filter matches **both** `$.actors[*]` (names) and `$.actors[*].name` (legacy objects). The director is also now carried through the bulk movie path. Re-run "Match metadata" to backfill cast on existing items. - **HTTP worker no longer segfaults under the Swoole coroutine runtime (`worker[phlix-server-http] exit with status 139`).** `start.php` enabled `Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL)`, which hooks *every* native call — file IO (io_uring), `proc_open`/`shell_exec`/`exec` (the on-demand HLS/CMAF transcode spawns a detached `ffmpeg`) and native curl — and re-drives each on the coroutine scheduler. On the production stack (**PHP 8.5 + Swoole 6.2.1 + kernel 7 with `io_uring` enabled**) those hooks crashed the worker with recurring general-protection faults **inside `swoole.so`** (175 such exits observed in `workerman.log`; `dmesg` traps point at `swoole.so`, and the crashes correlate with transcode spawns in the request path). New `Phlix\Server\Runtime\SwooleRuntime` applies a **curated allowlist** of just the network + sleep hooks (`TCP/UDP/UNIX/UDG/SSL/TLS/STREAM_FUNCTION/SLEEP/SOCKETS`); every other hook — file IO/io_uring, `proc_open`, curl, stdio, and the unnamed blocking-function hook that re-drives `exec`/`shell_exec` (the ffmpeg spawn, the exact trigger) — is excluded **by construction** and runs as an ordinary blocking syscall, while the coroutine MySQL pool + network IO still yield. (An allowlist is used rather than `SWOOLE_HOOK_ALL` minus a blocklist because `SWOOLE_HOOK_BLOCKING_FUNCTION` is not a named constant in Swoole 6.2.1 yet its bit is set in `SWOOLE_HOOK_ALL`, so it can't be subtracted.) Configurable in `config/server.php`: `coroutine.enabled => false` hard-disables the hook (Swoole stays the event loop), and `coroutine.hook_flags` takes an explicit `SWOOLE_HOOK_*` bitmask override. NOTE — the durable fix is to align the runtime: PHP 8.5 + Swoole 6.2.1 + io_uring is bleeding-edge; also consider `swoole.enable_iouring=0` and a Swoole build verified against the installed PHP. - **The admin "Install plugin" form now accepts a GitHub repository URL (fixes "install failed" on a pasted repo URL).** `HttpInstaller` could only fetch a *direct archive* (`.zip`/`.tar.gz`/`.tgz`) or a `.json` stub, so pasting a repository URL like `https://github.com/detain/phlix-plugin-anidb` — which the admin UI and `plugin:install` CLI both invite — had no recognised extension: the installer downloaded the repo's HTML landing page and threw *"Unsupported plugin source extension"*, surfaced as a generic 422 "install failed". A new `Phlix\Plugins\Installer\SourceUrlResolver` rewrites a git-host repository URL to its default-branch tarball — `https://github.com/OWNER/REPO[.git]` → `https://github.com/OWNER/REPO/archive/HEAD.tar.gz` (and `/tree/BRANCH` → `…/archive/BRANCH.tar.gz`), also accepting the scheme-less `github.com/OWNER/REPO` and `git@github.com:OWNER/REPO.git` SSH forms. `HEAD` resolves to the default branch with no GitHub API call. The resolver runs inside `HttpInstaller::install()` (covering the admin API **and** the CLI, plus recursive `.json` stub `source` fields) and at the `PluginAdminController` scheme-guard boundary, so a scheme-less paste is accepted instead of 400-rejected. Direct archive URLs, `file://` paths, release-asset/`/archive/…` URLs and non-GitHub hosts pass through unchanged (idempotent). Verified end-to-end: the resolved tarball downloads and unpacks to a single root dir with `plugin.json` at the expected depth. - **The single media-item endpoint now returns the enriched shape (fixes blank covers/overview on the detail & player pages).** `GET /api/v1/media/{id}` (`WebPortalRouter::getMediaItem` and `MediaItemController::show`) returned the raw DB row — no `poster_url`/`poster_srcset`/`genres`/`overview`/`season_number`/`episode_title`/… — while only the *list* endpoint shaped its rows, so the detail page (`/app/media/:id`) and the player hero rendered a blank cover and empty metadata even when the title had TMDB data. The shaping logic was extracted to a shared `Phlix\Media\Library\MediaItemShaper` (`shape()` for list rows, `shapeDetail()` = the schema shape merged over the raw row + `streams`, preserving intro/outro markers, chapters and `metadata`). Both the single-item handlers and the list now use it, so the two entry points can't drift again. - **On-demand transcode now produces a browser-decodable H.264 stream (fixes "We couldn't prepare a playable version").** The CMAF/HLS encode ran `libx264` with no pixel-format or profile pin, so a 10-bit source (e.g. an HEVC "Main 10" file — common for 1080p TV rips) was re-encoded straight into **10-bit "High 10" H.264**, which no browser's Media Source Extensions / hls.js can decode: the playlist and segments served fine but hls.js raised a fatal decode error and the player fell back to the "can't prepare a playable version" notice. `FfmpegRunner::buildCmafCommand()` / `buildHlsCommand()` now force `-pix_fmt yuv420p -profile:v high -level 4.1` (8-bit 4:2:0 High@4.1, the universally browser-decodable baseline; overridable via `pix_fmt`/`profile`/`level` params) on every software H.264 re-encode. Separately, `TranscodeManager::computeHlsParams()` no longer takes the fast `-c:v copy` remux path for an H.264 *source* that is itself 10-/12-bit or 4:2:2/4:4:4 (`isBrowserSafeH264()` gate) — those are re-encoded to 8-bit instead of passed through unplayable. Verified end-to-end against a live HEVC Main 10 file: output is now `avc1.640029` (High@4.1, `yuv420p`) vs the old undecodable `avc1.6e0028` (High 10). - **Honor HTTP `Range` requests in audiobook & photo streaming.** `AudiobookController::streamAudiobook()` and `PhotoController::getFull()` read the range header via raw `$request->headers['Range']` array access, but `Request::parseHeaders()` stores header keys upper-cased (`RANGE`), so the mixed-case lookup never matched and every range/seek request silently fell through to a full `200` instead of a `206` partial response. Both now read via the case-insensitive `Request::getHeader('Range')`, restoring seek/resume. ### Added - **A-Z jump index for the media list — `GET /api/v1/media/letter-index?<same filters>`.** For the default name-ascending sort, returns the absolute item offset of the first title in each first-letter bucket (`#` folds non-alphabetic first characters and is placed first), honoring the SAME filters as `/api/v1/media`. The list endpoint's filter-building was extracted to a shared `ItemRepository::buildFilters()` so the list and the index can't drift. Drives the SPA's A-Z jump rail, which scrolls the pre-sized grid to `offset` (empty buckets are returned with count 0 so the rail can disable them). - **Live progress for the "Match metadata" run.** `LibraryMetadataMatcher::matchLibrary()` now takes an optional progress callback and reports `processed`/`total` per 100-item batch (`total` = the library's top-level movie+series count). `LibraryScanWorker` forwards it onto the job row — `items_found` = total, `items_updated` = processed — so `GET /api/v1/libraries/{id}/scan-status` exposes a real percentage (`items_updated / items_found`) while a metadata match runs, instead of just `queued → running → completed`. (Scan/rescan jobs still report lifecycle only — `LibraryManager::scanLibrary()/rescanLibrary()` emit no per-item counts yet.) - **Filter the media list by match status — `GET /api/v1/media?match=matched|unmatched`.** Backed by `media_items.metadata_refreshed_at` (NULL = never metadata-matched), so the UI can surface items still needing a metadata pass (`unmatched`) or already-enriched ones (`matched`). Composes with every other filter and with the `total` count. - **TV series / season / episode metadata matching (fixes blank series, season & episode info + covers).** `LibraryMetadataMatcher` previously matched only `movie`/`video` items, so every series, season and episode got zero TMDB data — blank posters and empty synopses across all TV. New `SeriesMetadataResolver` (TMDB TV) + `TmdbProvider::searchTv()/getTvDetails()/getTvSeason()` resolve a series by name (+ optional first-air year), persist its poster/backdrop/overview/genres/year, then enrich the whole subtree from one `/tv/{id}/season/{n}` call per season: each season inherits the season (or series) poster + overview, and each episode gets its TMDB title, still, overview, air date and runtime — falling back to the series poster so nothing renders blank. Wired into the existing per-library "Match metadata" run (`SeriesMetadataResolver` injected into `LibraryMetadataMatcher`); `TmdbProvider`'s HTTP client is now injectable for testing. Uses the same admin-managed TMDB key as movie matching (Settings → Metadata). - **3.5 SyncPlay group watching UI + theme switcher.** New `/admin/syncplay` SPA page listing SyncPlay groups with Create/Join/Leave actions. New `SyncPlayApi` (`admin-ui/src/api/syncPlay.ts`) + `SyncPlayPage` (`admin-ui/src/pages/SyncPlayPage.tsx`) + 10 Vitest tests. New `SyncPlayController` (`src/Server/Http/Controllers/SyncPlayController.php`) wrapping `SyncPlayManager` with 5 REST endpoints: `GET/POST /api/v1/syncplay/groups`, `GET /api/v1/syncplay/groups/{id}`, `POST /api/v1/syncplay/groups/{id}/join|leave`. Theme switcher added to SSR settings page (`public/templates/settings/index.tpl`) — "Appearance" section with auto-save on change, no Save button required. `UserRepository.updateSettings()` now handles `theme` field writing to `user_profiles.active_theme_id`; `getSettings()` reads it back. `WebPortalRouter.extractSettingsPayload()` adds `theme` to the allow-list. Vitest: 10/10 on `syncPlay.test.ts`; phpstan level 9: pass; phpcs PSR12: pass; PHPUnit Unit: 2696 tests pass. - **2.5 Live TV / DVR admin SPA page (`/admin/live-tv`) — 4-section UI (Tuners, Guide/EPG, Recordings, Series Rules) consuming 20 LiveTV API endpoints from step 2.4.** New SPA: `admin-ui/src/pages/LiveTvPage.tsx` (4 collapsible sections — all collapsed by default, expand triggers lazy data load; Schedule Recording modal pre-fills from Guide; Add Rule modal with channel picker loading in parallel with rules; form validation with `form__error` messages). New `admin-ui/src/api/liveTv.ts` (`LiveTvApi`: 20 typed wrappers across 5 resource groups — tuners 5, channels 4, guide 3, recordings 6, seriesRules 5). `useToast()` destructured as `const { push: pushToast } = useToast()` (stable reference). All buttons use `disabled={isLoading} aria-busy={isLoading}` pattern. Defensive optional chaining on all state variable length accesses for React StrictMode compatibility. 32 Vitest tests: 22/22 on `liveTv.test.ts` (100%), 10/10 on `LiveTvPage.test.tsx` (100%), ≥80% on `LiveTvPage.tsx`. PHPStan level 9: pass. PHPCS PSR12: pass. PHPUnit Unit: 2696 tests pass (10 skipped, 7092 assertions). RemoteAccessPage (14/14) confirmed no regression. - **2.4 Live TV / DVR REST API (20 admin-gated endpoints).** Tuners (list/get/scan/update/delete), channels (list/get/update/stream), guide (list/refresh + program lookup), recordings (list/get/create/delete/upcoming/by-series), series rules (list/get/create/update/delete). New `AdminLiveTvController` (`src/Server/Http/Controllers/Admin/AdminLiveTvController.php`) — 964 lines, 20 endpoints wired under `AdminMiddleware` in `Application::loadLiveTvAdminRoutes()`. Manager classes (`LiveTvManager`, `ChannelManager`, `GuideManager`, `Recorder`, `SeriesRuleManager`) resolved via `$this->container->get()`. Migration `028_livetv_base.sql` creates 6 `livetv_*` tables with `CREATE TABLE IF NOT EXISTS` (`livetv_tuners`, `livetv_channels`, `livetv_programs`, `livetv_favorites`, `livetv_lineups`, `livetv_lineup_channels`). DVB-T scan deferred (stubbed `DvbtTunerDriver::performChannelScan` not exposed). PHPStan level 9: pass. PHPCS PSR12: pass. PHPUnit Unit: 2696 tests pass (10 skipped, 7092 assertions). No SPA in this step — UI arrives in step 2.5. - **2.3 Remote access admin SPA page (`/admin/remote-access`) — hub pairing, subdomain, relay tunnel, port-forward.** New `admin-ui/src/pages/RemoteAccessPage.tsx` (4 collapsible sections: Hub Pairing, Subdomain, Relay Tunnel, Port Forward — all collapsed by default, expand triggers lazy data load). New `admin-ui/src/api/remoteAccess.ts` (`RemoteAccessApi`: 16 typed wrappers across 4 resource groups — `getHubStatus`/`pairHub`/`unenrollHub`/`sendHeartbeat`/`getRelayCandidates`, `getSubdomainStatus`/`claimSubdomain`/`releaseSubdomain`/`updateSubdomain`/`verifySubdomain`, `getRelayStatus`/`enableRelay`/`disableRelay`/`pingRelay`, `getPortForwardStatus`/`togglePortForward`). New `AdminHubController` (`src/Server/Http/Controllers/Admin/AdminHubController.php`) exposing all 16 endpoints wired under `AdminMiddleware` in `Application::loadRemoteAccessRoutes()`. `useToast()` destructured as `const { push: pushToast } = useToast()` (stable reference). `togglePortForward` propagates HTTP 500 with `{ success: false, message: "…" }` as an error toast. Vitest: 22/22 on `remoteAccess.test.ts` (100%), 14/14 on `RemoteAccessPage.test.tsx` (100%), ≥80% on `RemoteAccessPage.tsx`. Overall SPA: 36 passing tests. - **Admin SPA: DLNA server status/toggle (step 2.2).** New `admin-ui/src/pages/DlnaServerPage.tsx` (`/admin/dlna-server`) — status card showing green/red running indicator, friendly name, Start and Stop buttons with `aria-busy` loading state and toast feedback (success, error, and info-toast on 409 already-running/stopped no-op). New `admin-ui/src/api/dlnaServer.ts` (`DlnaServerApi`: `getStatus()`/`start()`/`stop()`). New `src/Server/Http/Controllers/Dlna/AdminDlnaServerController.php` exposing `GET /api/v1/admin/dlna/status` (returns `{ running, enabled, friendly_name, uptime_seconds }`), `POST /api/v1/admin/dlna/start`, and `POST /api/v1/admin/dlna/stop`, all wired under `AdminMiddleware` in `Application::loadDlnaAdminRoutes()`. `CdsServer` is injected via `setCdsServer()`; when the container has no `CdsServer` registration the controller returns `enabled: false` gracefully. `useToast()` destructured as `const { push: pushToast } = useToast()` (stable reference). Vitest: 8/8 on `dlnaServer.test.ts` (100%), 10/10 on `DlnaServerPage.test.tsx` (100%), ≥80% on `DlnaServerPage.tsx`. Overall SPA: 18 passing tests. - **Admin SPA: Stats & dashboard page (1.6)** — replaced the Phase-0 placeholder with a rich 5-section dashboard at `/admin/dashboard`. **Now Playing** (live list with progress bars, 30s auto-refresh), **Top Users** (30d leaderboard table), **Top Media** (30d ranked list with type badges), **Storage** (breakdown cards by media type + transcode cache), **Recent Activity** (paginated feed with event-type badges). New `admin-ui/src/api/dashboard.ts` (`DashboardApi`: `getNowPlaying`/`getTopUsers`/`getTopMedia`/`getStorage`/`getActivity`) + `admin-ui/src/api/stats.ts` (`StatsApi`: `getPlaybackStats`/`getTopUsers`/`getTopMedia`/`getStorageStats`). Date range filter (7d/30d/90d) affects Top Users/Top Media/Activity. All 5 sections have loading skeletons + empty states. `useToast()` destructured as `const { push: pushToast } = useToast()` (stable reference). No `dangerouslySetInnerHTML`. No new PHP — consumes existing `DashboardController` + `StatsController` endpoints (already wired in `AdminRoutes`). Vitest: 301/302 tests (99.7%); dashboard.ts 100%, stats.ts 100%, DashboardPage.tsx ≥80%. - **Admin SPA: Services page (1.4c) — Trakt.tv OAuth connect/disconnect + Last.fm scrobbling connect/disconnect.** New `admin-ui/src/pages/ServicesPage.tsx` (`/admin/services`) — two-card layout: **Trakt.tv** card (connected/not-connected badge, Connect button navigates via `window.location.href` to `/api/v1/oauth/trakt`, Disconnect button POSTs to `/api/v1/admin/services/trakt/disconnect`); **Last.fm** card (connected/not-connected badge, Connect navigates to `/admin/lastfm`, Disconnect POSTs to `/api/v1/admin/services/lastfm/disconnect`). Status polled on mount via `GET /api/v1/admin/services/trakt/status` and `GET /api/v1/admin/services/lastfm/status`. New `admin-ui/src/api/trakt.ts` (`TraktApi`) and `admin-ui/src/api/lastfm.ts` (`LastfmApi`). Backend adds four endpoints to `LastfmController` (`status()`, `apiDisconnect()`) and `TraktOAuthController` (`status()`, `disconnect()`), all wired under `AdminMiddleware`. Last.fm smarty routes remain registered for the connect callback; `client_secret` never leaves the server. Vitest: 100% on `services.test.ts`, `ServicesPage.test.tsx`; 71.42% on `trakt.ts` and `lastfm.ts` (uncovered = `window.location.href` browser redirects, untestable in Node). - **Admin SPA: Backup / restore page (1.5) — backup list with create/restore/delete/S3 upload + schedule settings.** New `admin-ui/src/pages/BackupPage.tsx` (`/admin/backup`) — two sections on a single route: **Backup list** card with "Create backup" button (optional label input → `POST /api/v1/admin/backup/create`), a `DataTable` listing all backups (Label/Size/Created/S3 status/Actions columns), per-row Restore modal ("This will overwrite your current data. Continue?" + Cancel/Restore), Delete modal ("Are you sure? This cannot be undone." + Cancel/Delete), and Upload to S3 button (hidden when `is_s3 === true`); **Scheduled backups** card with interval + retention form pre-filled from `GET /api/v1/admin/backup/schedule`, saved via `PUT /api/v1/admin/backup/schedule`, displaying next backup as relative and absolute time. New `admin-ui/src/api/backup.ts` (`BackupApi`) with 7 typed wrappers: `list()`/`create()`/`delete()`/`restore()`/`uploadToS3()`/`getSchedule()`/`updateSchedule()`. `Backup` shape (7 fields: id/label/file_path/size_bytes/checksum_sha256/is_s3/created_at + expires_at null); `BackupSchedule` shape (auto_backup_interval_days/retention_count/next_scheduled_backup/next_scheduled_backup_iso). Backend wires all 7 endpoints in `Application.php` via `loadBackupRoutes()` under `AdminMiddleware`. `useToast()` destructured as `const { push: pushToast } = useToast()` (stable reference, no `useEffect` re-runs). No `dangerouslySetInnerHTML`. Vitest: 100% on `backup.ts` and `BackupPage.test.tsx`; 89.23% on `BackupPage.tsx` (≥80% target). Overall SPA: 95.67% statements, 88.38% branches, 83.12% functions, 95.67% lines. - **Admin SPA: Integrations page (step 1.4b) — Arr TRaSH-Guides sync + OIDC/LDAP auth provider config.** New `admin-ui/src/pages/IntegrationsPage.tsx` (`/admin/integrations`) — two sections: **Arr sync** (TRaSH-Guides-compatible Sonarr/Radarr/Bazarr/Prowlarr metadata sync) with last-sync status card, "Sync now" manual-trigger button (30 s timeout guard, spinner during call), and enable/disable auto-sync toggle; **Auth providers** listing OIDC + LDAP with per-provider enable/disable, inline configure forms (OIDC: provider_url/client_id/client_secret/scopes; LDAP: host/port/ssl/base_dn/bind_dn/bind_pw/user_filter/admin_group with show/hide toggles and "Test connection" dry-run), all pre-filled from GET settings on expand. New `admin-ui/src/api/arrSync.ts` (`ArrSyncApi`) with `getStatus()`/`triggerSync()`/`setEnabled()` wrapping the sync controller contract; new `admin-ui/src/api/authProviders.ts` (`AuthProvidersApi` + `OidcApi` + `LdapApi`) wrapping the auth-provider/OIDC/LDAP controller contracts. Secret fields are write-only — GET settings never returns them, and blank POST values are omitted so the server keeps the existing value. Enabled state derived from `configured` boolean the server returns per provider. Vitest coverage: 100% on `arrSync.ts`, `arrSync.test.ts`, `authProviders.ts`, `authProviders.test.ts`, and `IntegrationsPage.test.tsx`; 81.71% on `IntegrationsPage.tsx` (uncovered = defensive error-path guards). Overall SPA: 95.92% statements, 89.23% branches, 82.79% functions, 95.92% lines. - **Admin SPA: Webhooks page with full CRUD + test (step 1.4a).** New `admin-ui/src/pages/WebhooksPage.tsx` (`/admin/webhooks`) — DataTable listing (name, URL, event-count badge, Edit/Test/Delete row actions), Add/Edit modal (name + URL + secret with Show/Hide + event checkboxes grouped by 5 categories), Delete confirm modal, Test result modal (green/red outcome display). New `admin-ui/src/api/webhooks.ts` (`WebhooksApi`) with `list()`/`create()`/`update()`/`remove()`/`test()` methods. `SUBSCRIBABLE_EVENTS` (7 events) and `WEBHOOK_EVENT_CATEGORIES` are hardcoded in the TS layer; `webhook.test` is excluded from the UI (internal to test). Secret is write-only — GET never returns it; edit form shows empty field with "(unchanged)" placeholder and omits `secret` from PUT when blank. `remove()` handles 204 No Content gracefully by mapping to `{ message: 'Webhook deleted' }`. `test()` parses the actual controller response (`success`/`success_count`/`failure_count`/`failures`) and `WebhooksPage` builds a human-readable message for display. Vitest coverage: 97.29% on `webhooks.ts`, 89.74% on `WebhooksPage.tsx`. - **Backend: `PUT /api/v1/admin/webhooks/{id}` route for editing webhooks (step 1.4a carry-fix).** `WebhookDispatcher::update(array{name?, url?, events?})` — partial-update method that only writes provided fields, uses a parameterized query, and logs changed fields. `WebhookAdminController::update()` — validates `id` (fail-fast 400), extracts only name/url/events, returns `200 { webhook }` on success. Route wired in `Application.php` alongside the existing index/create/delete/test routes. No new endpoints for the other four operations — those were already registered. - **Admin SPA: Settings page with 8 group tabs for server configuration (step 1.3).** New `admin-ui/src/pages/SettingsPage.tsx` (`/admin/settings`) renders all 15 allow-listed server settings across 8 tabbed sections (Transcoding, Metadata, Markers, Subtitles, Discovery, Trickplay, Newsletter, Port Forward). No new backend endpoints — the page consumes the 0.5 GET/PUT `/api/v1/admin/settings` contract already shipped in step 0.5. Field types drive the control: `bool` → toggle switch; `int`/`float` → number input with `min`/`max` from schema constraints; `tmdb.api_key` → password input with Show/Hide toggle. Overridden keys (DB-persisted vs. config-file default) display a "custom" badge driven by the `overridden` array in the GET response. Dirty-state gating keeps the Save button disabled when no fields have changed. `PUT /api/v1/admin/settings { settings }` on save; 200 re-renders with refreshed `overridden`; 400 surfaces per-field inline errors; 500 shows an error toast. New `admin-ui/src/api/settings.ts` (`SettingsApi`) wraps the GET/PUT contract with envelope unwrapping; both methods throw `ApiError` on non-2xx. Vitest coverage: 100% on `settings.ts` and `SettingsPage.test.tsx`, 88.16% on `SettingsPage.tsx`. - **Admin profile management API: list, get, create, update, delete, set-pin, delete-pin endpoints (step 1.2b).** New `src/Server/Http/Controllers/Admin/AdminProfileController.php` with 7 REST endpoints for managing user profiles (`GET /api/v1/admin/users/{userId}/profiles`, `POST /api/v1/admin/users/{userId}/profiles`, `GET /api/v1/admin/profiles/{id}`, `PUT /api/v1/admin/profiles/{id}`, `DELETE /api/v1/admin/profiles/{id}`, `POST /api/v1/admin/profiles/{id}/pin`, `DELETE /api/v1/admin/profiles/{id}/pin`). Routes are registered inside the existing `AdminRoutes` group with `AdminMiddleware` gating. Enforces ≤5 profiles per user (400 when limit reached), validates PIN as exactly 4 or 6 digits (400 for other lengths), and supports clearing PIN via null/empty string. Unit tests cover ~100% of the new controller. - **Admin user management API: list, get, create, update, delete, set-admin, reset-password endpoints (step 1.2a).** New `src/Server/Http/Controllers/Admin/AdminUserController.php` with 7 REST endpoints for managing server users (`GET /api/v1/admin/users`, `GET /api/v1/admin/users/{id}`, `POST /api/v1/admin/users`, `PUT /api/v1/admin/users/{id}`, `DELETE /api/v1/admin/users/{id}`, `POST /api/v1/admin/users/{id}/set-admin`, `POST /api/v1/admin/users/{id}/reset-password`). Routes are registered inside the existing `AdminRoutes` group with `AdminMiddleware` gating. Passwords are hashed with Argon2ID via `password_hash(PASSWORD_ARGON2ID)`; `reset-password` generates a random 12-character password returned in the response for admin sharing. Last-admin guard prevents deleting or demoting the final admin user; self-delete/self-demotion is blocked. `UserRepository` gained `findAll()`, `delete()`, `countUsers(string $predicate)`, and `emailExists(string $email, ?int $excludeId)` to support the controller. Unit tests cover ~100% of the new controller. - **Library management admin page (step 1.1c).** New `/admin/libraries` SPA page in the admin console — the first real feature page on top of the 0.4 scaffold. Lists every library (name, type, path count, live scan-status badge) in the shared `DataTable`; an **Add library** modal + form posts `{name, type, paths, options?}` to `POST /api/v1/libraries`; an **Edit** modal pre-fills the same form and `PUT`s `{name, paths}` (the controller ignores `type`, and the form shows it read-only); a **Delete** confirm modal hits `DELETE /api/v1/libraries/{id}`. Path entry uses a new reusable `PathPicker` component driving the 0.6 `GET /api/v1/admin/fs/browse` endpoint (roots → drill-down → select; jailed to the configured `browse_roots`). Per-row **Scan** / **Rescan** buttons consume the **async** 1.1b API: they `POST .../scan|rescan` → `202 {job_id, status: "queued", message}` and the page starts polling `GET .../scan-status` every 2 s for that library (interval period injectable via `pollIntervalMs`). Polling **stops** on a terminal status (`completed`/`failed`) or `null`, and every outstanding interval is cleared on unmount via a `useRef` of per-library timers — no leaked timers, no global mutable state. Progress is **coarse / lifecycle-only** by design (the 1.1b worker leaves `items_*` at `0` and `current_path` at `null`), so the UI renders the status badge + `error` string only and deliberately does **not** draw a fabricated per-file progress bar. A per-library **History** modal loads `GET .../scan-history?limit=20` (server clamps `[1,100]`, newest first) into a `DataTable`. The `book` library type is **deliberately excluded** from the type select: the `libraries.type` ENUM (migration 001) is exactly `movie|series|music|photo|video`, even though `LibraryController::create()` *also* lists `book` in `$validTypes` (a `book` insert would 500 at the DB ENUM — pre-existing backend mismatch, carry-over for a later step). New `admin-ui/src/api/libraries.ts` (`LibrariesApi`) and `admin-ui/src/api/filesystem.ts` (`FilesystemApi`) are typed 1:1 wrappers over the `ApiClient` that unwrap the single-key envelopes (`{libraries}`, `{library}`, `{scan_status}`, `{history}`, fs-browse `{success, data}`) so callers get the bare domain object; non-2xx still throws `ApiError`. `LibrariesPage` adds a sidebar entry under Dashboard and a `<Route path="/libraries">` in `App.tsx`. Architecture note worth knowing: the page destructures the **stable** `push` callback from `useToast()` (`const { push: pushToast } = useToast();`) rather than depending on the whole context value — the provider memoises `[toasts, push, dismiss]`, so depending on the context object re-fires every `useCallback`/`useEffect` on every toast push (which during a scan would consume the next-mocked response as a stray `GET /libraries` and crash `DataTable`). All four test files (`libraries.test.ts`, `filesystem.test.ts`, `PathPicker.test.tsx`, `LibrariesPage.test.tsx`) drive a real `ApiClient` through the `makeFetch` concrete-mock helper against REAL-shaped responses (the 0.4 fabricated-contract lesson). Vitest coverage: **98.73%** statements overall; per file `libraries.ts`/`filesystem.ts` 100%, `PathPicker.tsx` 98.24%, `LibrariesPage.tsx` 95.62% (uncovered ≈ defensively-unreachable guards and `||`-fallback templates). **PHP side untouched** — no controller, route, migration, or worker change; only the committed admin-ui source + the rebuilt `public/assets/admin/` bundle. - **Async library-scan worker + scan-status/scan-history endpoints (step 1.1b).** Moves library scanning off the HTTP request and onto a Workerman-native managed worker process that drains the 1.1a `library_scan_jobs` queue. New `src/Media/Library/LibraryScanWorker.php` (`Phlix\Media\Library`): `runOnce()` atomically claims the oldest queued job via `ScanJobRepository::claimNext()` (returns `false` when nothing is queued), runs the existing `LibraryManager::scanLibrary()`/`rescanLibrary()` by `type`, then `markCompleted()` on success or `markFailed($jobId, $e->getMessage())` on any `\Throwable` (returns `true` either way — a job was processed); a claimed row missing a usable `id`/`library_id` is defensively logged + skipped. `start(int $pollSeconds)` installs a `Workerman\Timer` that calls `runOnce()` once per tick — **never a blocking `sleep()`** (the legacy `BackgroundDetectorWorker::runLoop()`'s `sleep()` is the resident-memory violation this worker deliberately does not copy); a backlog of N drains in ≤ N ticks. **Progress is coarse by design** — `scanLibrary()`/`rescanLibrary()` return `void` with no counts, so the worker records the honest `queued → running → completed/failed` lifecycle and leaves `items_*` at 0 (no fabricated counts; no scan-internals expansion). New `config/process.php` is the single source of truth for the worker settings (`library-scan` => `enabled`/`count:1`/`poll_seconds:5`) in the conventional Webman filename, but carries PLAIN settings because this app boots through a hand-rolled `start.php` (not `support\App::run()`), so the file is read explicitly rather than auto-consumed by the framework. Two run paths read it: `start.php` now spawns the worker as a managed `count:1` sibling `Worker` under the same `Worker::runAll()` process group (additive + guarded — a worker build failure cannot take down the HTTP workers), and the standalone `scripts/run-library-scan-worker.php` runs it as its own isolated service (e.g. a dedicated systemd unit); running both at once is safe because `claimNext()` is atomic and each worker is `count:1`. `LibraryScanWorker` is autowired in `MediaServicesProvider`. New read endpoints: `GET /api/v1/libraries/{id}/scan-status` → `200 { scan_status: <latest job row|null> }` (a library with no jobs yet is a valid `200` with `null`, not a `404`) and `GET /api/v1/libraries/{id}/scan-history?limit=N` → `200 { history: [<job row>, ...] }` (newest first; `limit` defaults to 20, clamped to `[1,100]` by the repo). Both are admin-gated (least-privilege — `current_path` is a server filesystem path; the 1.1c progress page is admin-only) and `404` on a missing library. Wired in `Application::loadLibraryRoutes()` (now 9 LibraryController routes); the Router compiles `{id}` to `[^/]+` and anchors patterns with `#^...$#`, so the 2-segment `{id}` (show) route cannot match these 3-segment literal paths and vice-versa — no shadowing in either direction regardless of registration order. Verified by unit tests: `LibraryScanWorkerTest` (every `runOnce` branch — scan, rescan, nothing-queued, scan-throws→markFailed, rescan-throws→markFailed, defensive bad-row, unknown-type→scan) and the rewritten/extended `LibraryControllerTest` (scan/rescan enqueue+202 with `scanLibrary`/`rescanLibrary` asserted never-called, scan-status happy/null/404/401, scan-history happy/limit/404/401). - **Library scan-job data layer (step 1.1a).** A DB-backed store that records the lifecycle of a library scan (`queued → running → completed/failed`) plus its progress counters — the foundation the 1.1b async scan worker writes to and the scan-status/scan-history endpoints read from. **No behaviour change in this step** (no controller/worker is wired yet). New migration `migrations/027_library_scan_jobs.sql` creates the `library_scan_jobs` table (`id` CHAR(36) UUID PK, `library_id` CHAR(36) with `fk_lsj_library` FK → `libraries(id) ON DELETE CASCADE`, `type` ENUM `scan|rescan`, `status` ENUM `queued|running|completed|failed`, `items_found`/`items_added`/`items_updated`/`items_removed` counters, nullable `current_path`/`error`, and `queued_at`/`started_at`/`completed_at` timestamps; `idx_lsj_library`, `idx_lsj_status`, `idx_lsj_library_queued` indexes; `CREATE TABLE IF NOT EXISTS` so the migration runner can replay it idempotently). New `src/Media/Library/ScanJobRepository.php` (`Phlix\Media\Library`) exposes `enqueue()` (inserts a `queued` row; rejects a `type` other than `scan|rescan` with `InvalidArgumentException`), `claimNext()` (atomically claims the oldest `queued` job via a conditional `UPDATE ... WHERE id=? AND status='queued'`, honouring the claim only when the affected-row count is ≥ 1 so a double-claim can't slip through; returns the claimed row or `null` when nothing is queued), `updateProgress()`/`markCompleted()` (write only the recognised `items_*` counters), `markFailed()`, `findById()`, `getLatestForLibrary()` (powers `scan-status`), and `getHistoryForLibrary()` (powers `scan-history`; clamps `$limit` to `[1, 100]`). All access is through the async `Workerman\MySQL\Connection` client with parameterised queries; UUIDs come from the local `generateUuid()` helper; rows are defensively decoded (int counters, nullable timestamps). Autowired in `MediaServicesProvider`. **The `claimNext`/`updateProgress`/`mark*` methods are intentionally unused in this PR — they are consumed by the 1.1b worker.** Verified by unit tests (mocked `Connection`, every method, both `claimNext` branches, the invalid-type reject, the `$limit` clamp) and a real-DB round-trip integration test (`enqueue → claimNext → updateProgress → markCompleted`) that self-skips when no MySQL is reachable. - **CI: "Admin UI" GitHub Actions workflow builds + Vitest-tests the admin SPA (step 1.0).** New `.github/workflows/admin-ui.yml` (workflow `Admin UI`, single job `Admin UI Build + Test` on `ubuntu-latest`) runs `npm ci → npm run build` (`tsc --noEmit && vite build`) → `npm run test` (`vitest run`) with `working-directory: admin-ui` on every `push`/`pull_request` to `master`/`main`/`develop`. It is **path-filtered** to `admin-ui/**` and the workflow file itself, so PHP-only PRs don't spin up Node while SPA changes (and the open Vite 5→8 dependabot PR #131) do trigger it. Node is pinned to LTS `20` via `actions/setup-node@v4` with npm cache keyed on `admin-ui/package-lock.json`; `actions/checkout@v6` matches the sibling workflows. Least-privilege `permissions: contents: read` (no write scope, no secrets) keeps it safe for fork PRs. This closes the 0.4 carry-over where the SPA build + 55 Vitest tests ran only locally; build is green and 55/55 Vitest tests pass. - **`bin/phlix` service-wrapper commands (step 0.8b).** Eleven thin console commands built on the 0.8a CLI machinery, each registered on the same `bin/phlix` application: `library:list` (lists all libraries via `LibraryManager::getAllLibraries()`), `library:scan {libraryId} [--rescan]` (`LibraryManager::scanLibrary()` / `rescanLibrary()`), `plugin:list` (`PluginLoader::listInstalled()` with enabled state), `plugin:enable {name}` / `plugin:disable {name}` / `plugin:install {source}` / `plugin:uninstall {name}` (the `PluginLoader` lifecycle — `install` prints the resulting plugin name + version), `backup:create [--label=]` (`BackupManager::createBackup()`, prints id/path/size) / `backup:list` (`BackupManager::listBackups()`), `hwaccel:probe` (`HwaccelProbe::probe()`, renders detected vendors/encoders/codecs), and `user:reset-password {user} [--password=]` (looks the user up by username then email via `UserRepository::findByUsername()`/`findByEmail()`, then `UserRepository::update(['password' => …])` which Argon2ID-hashes — when `--password` is omitted a strong random password is generated with `bin2hex(random_bytes(12))` and printed). Each command takes a per-service factory `callable` and resolves its backing service LAZILY from the PHP-DI container only inside `execute()`, so `php bin/phlix list` still builds NO container and touches NO database; `bin/phlix` wires those factories behind a single memoizing container-provider closure (`$container ??= ContainerFactory::create($config)`) that replicates `start.php`'s config assembly minus the Swoole/worker bootstrap. Commands never `exit()`/`die()`; they `return` `Command::SUCCESS` (0) on success and `Command::FAILURE` (1) on a thrown/“not found” failure (error messages are rendered with `<error>` markup). Verified by one `CommandTester` unit test class per command (mocked services, success + failure paths; `user:reset-password` additionally covers found-by-username, found-by-email fallback, not-found→exit 1, missing-id→exit 1, explicit `--password`, and generated-password). - **`webman/console` CLI baseline + `bin/phlix migrate` (step 0.8a).** Added `webman/console` and a custom `bin/phlix` executable so the project has a real CLI (closing the long-standing "bin/phlix doesn't exist" gap). Because `webman/console` only auto-discovers commands from an `app/command` directory this repo doesn't have, `bin/phlix` instead explicitly registers `Phlix\Console\Commands\*` instances on a `Webman\Console\Command` application (which extends Symfony's Console Application) and runs it — `php bin/phlix list` shows the commands, `php bin/phlix migrate` runs them. The migration-apply logic that lived inline in `scripts/run-migrations.php` is extracted into a new testable service `src/Common/Database/MigrationRunner.php` (`Phlix\Common\Database`): it discovers `migrations/*.sql` via `glob()`+`sort()`, splits each file into statements (stripping `--` and `/* */` comments so a `;` inside a comment never shreds a statement), runs each via `Workerman\MySQL\Connection::query()`, and downgrades the known idempotent-replay errors (`Duplicate column name` / `Duplicate key name` / `check that column/key exists` / `already exists`) to notes instead of failures. There is **no migration-tracking table** — every file is applied on every run, preserving the apply-all-every-time contract that `docker/docker-entrypoint.sh` and `scripts/install.sh` depend on. The connection is resolved lazily (only inside `run()`), so `bin/phlix list` and command construction work with no database available. `MigrateCommand` (`Phlix\Console\Commands`) renders a human summary and returns exit code `0` on success / `1` when a genuine non-idempotent error occurred. `scripts/run-migrations.php` is now a thin shim that boots the same `MigrationRunner` and prints the same summary with the same exit semantics, so the docker/install callers are unaffected. Verified by unit tests: `MigrationRunnerTest` (mocked `Connection`, temp fixture `.sql` files — sort order, comment-aware splitting, idempotent-downgrade vs genuine-error branches, empty-dir, no-connection-at-construction) and `MigrateCommandTest` (via Symfony `CommandTester` — exit 0 on success/notes, exit 1 on a genuine error). Wrapper commands for the other scripts (`library:*`, `plugin:*`, `backup:*`, `hwaccel:probe`, `user:reset-password`) land in step 0.8b. - **Admin filesystem-browse endpoint for the library path picker (step 0.6).** New `GET /api/v1/admin/fs/browse?path=…` (`src/Server/Http/Controllers/Admin/FsBrowseController.php`) lists the immediate **subdirectories** of `path` (directories only — never files; no read/write/delete) so a future "add library" UI can offer a path picker. New `config/filesystem.php` defines the `browse_roots` allow-list (default `['/home', '/mnt', '/media', '/data']`) — the security boundary the listing is jailed to (env override deliberately omitted to keep the boundary explicit/auditable). Traversal safety mirrors the canonical `AudiobookController::validateMediaPath()` jail: every candidate path is canonicalised with `realpath()` (which collapses `..` and resolves symlinks) and checked against each root with a trailing-slash prefix test (`$real === $root || str_starts_with($real . '/', $root . '/')`, **never** `str_contains`), so `..` escapes, symlinks pointing outside the jail, and non-allowed roots are all rejected with `403`. Status mapping: empty/absent `path` → `200` returning the configured roots as the entry list (`data.path`/`data.parent` = `null`); `realpath()` fails (non-existent) → `404`; resolves but not a directory → `400`; resolves outside the jail → `403`; valid dir under a root → `200` `{ success, data: { path, parent, entries:[{name,path}] } }` (entries sorted by name, `parent` only when it is itself within the jail else `null`). The route sits in the existing `/api/v1/admin` group registered in `src/Server/Http/Routes/AdminRoutes.php`, gated by `AdminMiddleware` (non-admin callers get a JSON 401/403); bound via a `factory()` in `AdminServicesProvider` that loads `browse_roots` from config (roots that do not `realpath()`-resolve are dropped at construction). **API only — the path-picker / library-management UI lands in Phase 1.1.** Verified by unit tests covering all security paths (traversal/symlink-escape/non-allowed-root → 403, 404, 400, roots-list, parent-within-jail, ctor-drops-non-resolving-root); new-code coverage 91.1% (72/79 statements) — the only uncovered lines are the defensively-unreachable `catch (Throwable)` → 500 and `scandir() === false` arms (a valid, jail-checked, readable directory cannot trip them). - **Server-wide settings store + admin API (step 0.5).** A DB-backed store so admin settings pages have somewhere to persist (the `config/*.php` files are boot-time / read-only). New migration `migrations/026_server_settings.sql` creates the typed key/value `server_settings` table (`id` CHAR(36) UUID PK, unique `setting_key`, text `setting_value`, `value_type` ENUM `string|int|bool|float|json`, timestamps; `CREATE TABLE IF NOT EXISTS` so the migration runner can replay it idempotently). `src/Admin/SettingsRepository.php` (`Phlix\Admin`) models the runtime contract **config default → DB override → effective value**: the value baked into `config/<file>.php` is the baseline, a row in `server_settings` overrides it, and the effective value is the override when present else the default. Keys are *dotted* — the first segment names the config file and the rest walk the returned array (e.g. `hwaccel.enabled` reads `config/hwaccel.php['enabled']`, `port-forward.port_forwarding.upnp_enabled` walks two levels). Upserts use `INSERT ... ON DUPLICATE KEY UPDATE` (mirrors `UserRepository::updateSettings()`) exclusively through the async `Workerman\MySQL\Connection` client with parameterised queries; the config-file segment is regex-jailed (`^[A-Za-z0-9_-]+$`) against path traversal. New `src/Server/Http/Controllers/Admin/AdminSettingsController.php` exposes `GET /api/v1/admin/settings` (returns `{ success, data: { settings, overridden, types } }` — effective values, the list of overridden keys, and the allow-list type map) and `PUT /api/v1/admin/settings` (body `{ settings: { "<dotted.key>": value, ... } }`) which validates every submitted key against a typed allow-list (`ALLOWED_KEYS`): unknown keys → 400, wrong types → 400, **all-or-nothing** (nothing persists if any key fails), then upserts the overrides. Both routes sit inside the existing `/api/v1/admin` group registered in `src/Server/Http/Routes/AdminRoutes.php`, gated by `AdminMiddleware` (non-admin callers get a JSON 401/403). Persisted overrides **survive a restart** because the DB is the durable store. **API only — the settings UI lands in Phase 1.3.** Validation is inline pending step 0.7's shared `server-settings.schema.json`, which will later replace/back the `ALLOWED_KEYS` map (a `0.7:` seam comment marks the spot). Verified by unit + integration tests (round-trip persist → fresh repository re-read); new-code coverage: `SettingsRepository` 100% (103/103 statements), `AdminSettingsController` 98.8% (the single uncovered line is the defensively-unreachable `json` arm of `valueMatchesType()` — no allow-list key is type `json` yet). - **Admin SPA scaffold (step 0.4).** A React + TypeScript + Vite admin console now mounts at `/admin` + `/admin/*`, served by the new `src/Server/WebPortal/Controllers/AdminAppController.php` (returns the built `index.html` shell; 503 with an actionable "run `npm run build`" message when the bundle is absent) and gated by the existing `AdminMiddleware::checkAccess()` — a 401 (unauthenticated) or 403 (non-admin) maps to a 302 redirect to `/login`. The SPA source lives in `admin-ui/`; the production bundle is built into `public/assets/admin/` and **committed to the repo** (`admin-ui/node_modules/` is gitignored), so the running Workerman server has **no Node build dependency at runtime**. Dispatch is wired in BOTH entry points (`public/index.php` and `src/Server/Workerman/HttpHandler.php`), placed AFTER the existing `/admin/plugins` + `/admin/dashboard` SSR routes so those keep winning. The typed `ApiClient` (`admin-ui/src/api/client.ts`) reuses the existing JWT mechanism from `public/assets/js/api-client.js` (same `localStorage` keys `access_token`/`refresh_token`/`user`, Bearer header, single-retry-on-401 via `POST /auth/refresh`); `getCurrentUser()` consumes `GET /api/v1/auth/me`, unwrapping its `{ user: {...} }` envelope and normalising the DB `TINYINT` `is_admin` (`1`/`0`) to a real boolean. This is a working shell/scaffold only — nav, router, a typed API client, and shared components (DataTable, Form, Modal, Toast); no feature pages yet (those land in Phase 1). Verified by Vitest (~99% coverage on the new SPA modules) + an `AdminAppControllerTest` (shell 200 / 503-missing / 302-redirect). - **Bare-metal Swoole + php-uv build (step 0.3).** `scripts/install.sh` and `install/systemd.sh` now compile the Swoole and php-uv extensions from source as part of a fresh install (and on the `scripts/install.sh --update` repair path), giving the step 0.2 coroutine runtime real extensions on Debian/Ubuntu hosts — not just in Docker. The Swoole `./configure` flag set is copied **verbatim** from `docker/Dockerfile.base` (see `docker/README.md` "Swoole build flags" for the per-flag rationale); php-uv is built with `--with-uv`. The apt `-dev` build dependencies (`build-essential autoconf pkg-config git libssl-dev libuv1-dev libbrotli-dev libzstd-dev libnghttp2-dev libpq-dev libsqlite3-dev libc-ares-dev liburing-dev libssh2-1-dev`, plus the version-matched `phpX.Y-dev` for `phpize`) are the Debian translation of the Alpine set. The build is **idempotent**: each step short-circuits via `php -m` when the extension already loads, so re-running the installer never triggers the slow recompile. `--enable-iouring` / `--enable-uring-socket` build on any kernel but only activate at runtime on Linux kernel ≥ 5.6 (older kernels fall back to epoll automatically). - **Workerman disable-function preflight (step 0.3).** A new preflight in both installers fails loudly and early if `disable_functions` blocks any process-control / posix / socket primitive Workerman needs to fork workers and manage sockets (`pcntl_*`, `posix_*`, `proc_*`, `exec`/`shell_exec`, `stream_socket_*`), with an actionable message pointing the operator at their `php.ini` (and php-fpm pool config) — instead of a cryptic runtime crash after install. Uses an exact-token match (no substring false-positives). - **Swoole + php-uv loaded in the PHPUnit CI job (step 0.3).** The PHPUnit jobs in `.github/workflows/phpunit.yml` (`test` and `test-server`) now load both extensions — `swoole` via `shivammathur/setup-php` and php-uv via a source-build step — and verify them with `php -m | grep -iE '^(swoole|uv)$'` before the suite runs, so the full test suite exercises the coroutine runtime in CI. CI runs on host runners (not containerized); the existing MySQL service container and coverage steps are unchanged. Neither extension is added as a hard composer platform requirement. - **Coroutine runtime enabled (step 0.2).** `start.php` and `public/index.php` now set `Worker::$eventLoop = \Workerman\Events\Swoole::class` before any `Worker` instantiation, and call `Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL)` in the master process to enable full coroutine I/O. The code degrades gracefully with a `E_USER_WARNING` when ext-swoole is not yet available. - **Coroutine micro-bench (step 0.2b).** Added `scripts/bench/coroutine_bench.php` — a small, dependency-free smoke-test that fires N coroutines through `SWOOLE_HOOK_ALL`-hooked `time_nanosleep()` and asserts wall-clock ≤ 1.5× a single-unit run (so concurrent requests demonstrably do not serialize). Exits 0 on pass, 1 on fail, 2 if `ext-swoole` is absent. Local run: serial ≈ 100 ms, concurrent N=4 ≈ 102 ms (≈3.9× speedup over the serialized ≈ 400 ms baseline). The pre-existing `scripts/bench/concurrent_streams.php` still works but needs a live HLS endpoint + media-id and is not CI-friendly. ### Changed - **`web-ui`: bumped `@phlix/ui` to `v0.19.0` (admin composability + Hub Dashboard).** Behaviour-neutral for the server: `buildAdminRoutes()` (no args) still yields the same 16 admin routes/names, so the mounted admin section, the `/app/admin/dashboard` landing, and the sidebar order are unchanged. The committed SPA bundle under `public/assets/app/` was rebuilt against the new tag. (v0.19.0 makes the shared admin shell composable so the hub can mount its own page group; the server keeps the default set. Two unused lazy chunks — `HubDashboardPage` and `AuditLogsPage` — are now emitted into the bundle because `@phlix/ui`'s admin registry statically references the hub page group; the server router never registers those routes, so the chunks are never fetched at runtime.) - **WebAuthn/passkeys settings: routed `/settings/security` to `auth/webauthn-settings.tpl`.** Template now loads correctly (fixed broken `layouts/app.tpl` → `layouts/main.tpl`). Inline error messages replace `alert()` calls. Credential IDs are XSS-safe. - **Settings page: replaced thin-shell form with working GET/PUT form backed by `/api/v1/users/me/settings`.** Streams, bitrate, audio/subtitle language, subtitle mode, and parental control settings now persist correctly. - **`POST /api/v1/libraries/{id}/scan` and `.../rescan` now enqueue + return `202` instead of scanning inline (step 1.1b).** `LibraryController::scan()`/`rescan()` previously called `LibraryManager::scanLibrary()`/`rescanLibrary()` synchronously inside the HTTP handler (the blocking-HTTP async-rule violation) and returned `200 { message: "Library scan started" }`. They now keep the admin gate + library-existence `404` check, then `ScanJobRepository::enqueue($id, 'scan'|'rescan')` a job and return `202 { job_id, status: "queued", message: "Library scan queued"|"Library rescan queued" }`; the async `LibraryScanWorker` performs the actual scan off the HTTP path. `LibraryController`'s constructor gains a second `ScanJobRepository` parameter (passed in both branches of `Application::getLibraryController()` — the container branch resolves it from DI, the null-container fallback reuses the already-built connection; the pre-existing hardcoded fallback creds are left untouched as a separate follow-up). The CLI `bin/phlix library:scan` is unchanged and stays synchronous. - **`AdminSettingsController` now derives its editable-settings allow-list from the shared `server-settings.schema.json` (step 0.7).** Bumped `detain/phlix-shared` to `^0.7.0` and replaced the hardcoded `ALLOWED_KEYS` constant — the `// 0.7:` inline-allow-list seam is removed. The dotted-key → internal-type map (the PUT validation source and the GET `types` map) is now loaded once from the vendored schema (located via `Phlix\Shared\Schema\SchemaPaths::serverSettings()`) and cached in a static; each JSON-Schema `type` is mapped to the internal vocabulary (`boolean→bool`, `integer→int`, `number→float`, `string→string`, `array`/`object→json`). The schema declares exactly the same 15 settings keys with the same types, so GET/PUT behaviour and validation are unchanged; a missing/unparseable schema fails safe to an empty allow-list (a new lock-in unit test asserts the derived map equals the expected 15 keys/types so drift or a missing vendored schema is caught loudly). `valueMatchesType()`/`coerce()` and the `SettingsRepository`-only constructor are untouched. - **Upgraded to Webman 2.2 / Workerman 5.1.** Pinned `workerman/workerman` to `~5.1` and `workerman/webman-framework` to `~2.2` as a prerequisite for coroutine support (step 0.2). No other changes — routing, controllers, and DI wiring remain unchanged. - **Coroutine-safe per-request state via `support\Context` (step 0.2b).** Audited `src/Server/` for `protected|private|public static $`, `global $`, and `$GLOBALS[…]` carrying per-request data; the audit found **zero offenders** (only PHP's built-in `global $http_response_header;` for `file_get_contents()` exists, outside `src/Server/`). Introduced `Phlix\Server\Http\RequestContext` — a thin typed wrapper around `support\Context` — as the canonical place to publish and read per-request data (today: the authenticated user-id). `AdminMiddleware` now publishes `$request->userId` into the coroutine-local context on a successful admin gate so downstream services can read it without re-passing the `Request`, and explicitly does NOT publish anything on 401/403 paths. New `tests/Unit/Server/Coroutine/ContextIsolationTest.php` proves per-fiber isolation and exercises the `ext-swoole` graceful-fallback branch under a captured error handler. Documented end-to-end in `phlix-docs/docs/dev/coroutine-runtime.md` (eventLoop, hooks, no-static-state rule, `exit`/`die`/`sleep()` ban, contributor checklist). ### Fixed - **`start.php` Swoole eventLoop assignment used the wrong identifier (step 0.2c cumulative-pass).** The 0.2a PR (#126) shipped `Worker::$eventLoop = \Workerman\Events\Swoole::class`, which raised `Access to undeclared static property Workerman\Worker::$eventLoop` on every `php start.php <subcommand>` invocation (status / stop / restart / reload all crashed). Workerman 5's actual static is `Worker::$eventLoopClass` — `$eventLoop` is an *instance* property used to override the eventLoop on a single Worker. Fixed in `start.php`; added `tests/Unit/Server/Coroutine/EventLoopBootstrapTest.php` to guard against the typo regressing (asserts `$eventLoopClass` exists as a public static, `$eventLoop` exists as an instance, and the literal idiom from `start.php` compiles and assigns without fatal). ### Added - **Web-portal HTML pages for music, books, audiobooks, and photos.** The Smarty templates under `public/templates/{music,books,audiobooks,photo}/` existed but were never wired to a page route — only `home`, `library`, `auth`, and the admin dashboard rendered. Four SSR controllers now back them (`MusicPageController`, `BookPageController`, `AudiobookPageController`, `PhotoPageController` in `src/Server/WebPortal/Controllers/`), rendering via `PageRenderer::renderTemplate()` and sourcing data from the same managers as the JSON API. `public/index.php` routes the page paths: - Music: `/music` (albums), `/music/albums/{name}`, `/music/artists`, `/music/artists/{name}`, `/music/tracks`, `/music/player`. - Books: `/books`, `/books/{id}`, `/books/{id}/read`, plus `/books/{id}/cover` and `/books/{id}/download` delegating to `BookController`. - Audiobooks: `/audiobooks`, `/audiobooks/{id}`, `/audiobooks/{id}/read`. - Photos: `/photo/albums`, `/photo/album/{id}`, `/photo/photo/{id}`, `/photo/slideshow`, plus `/photo/photos/{id}/thumbnail` and `/photo/photos/{id}/full` delegating to `PhotoController`. Controllers are registered in `WebPortalServicesProvider`. Covered by unit + Smarty-render tests in `tests/Unit/Server/WebPortal/Controllers/`. ### Fixed - **Media page templates rendered nothing because of mismatched Smarty block names.** `layouts/main.tpl` and `layouts/player.tpl` only expose a `main` block, but the books/audiobooks templates declared `content` (and the audiobook player `player-content`) and every photo template declared `body` (which would have replaced the entire sidebar layout). All now use `main`. Also fixed a corrupted modifier in `music/artist.tpl` (`|自己不做了:00` → `|default:'0:00'`), a broken `{math}`/bareword duration expression and an unregistered-function `min()` call in `music/tracks.tpl`, and a missing-parenthesis duration modifier in `audiobooks/audiobook.tpl` that emitted a "non-numeric value" warning. `AudiobookPageController` normalizes `metadata.chapters` to an array so the detail/player templates can `count()`/iterate it without a TypeError. - **Media-library routes now share the `/api/v1` prefix with the rest of the JSON API.** `Application::loadMusicRoutes()`, `loadBookRoutes()`, `loadAudiobookRoutes()`, and `loadPhotoRoutes()` registered their endpoints at the bare root (`/music/...`, `/books/...`, `/audiobooks/...`, `/photo/...`) while `phlix-docs` `reference/api.md` and every other metadata route (auth, media, sessions, collections, libraries, cast, dlna) used `/api/v1`. Clients following the docs hit a 404. All music, book (non-OPDS), audiobook, and photo routes are now mounted under `/api/v1`, matching the docs. OPDS keeps its spec path `/opds/v1.2` (deliberately un-prefixed). The unused `Router::music()/books()/audiobooks()/photo()` convenience helpers were aligned to the same prefix so they no longer document a contradictory layout. Guarded by `RouterMediaRoutesTest` (unit) and `ApplicationTest::testMediaRoutesAreRegisteredUnderApiV1` (integration, asserts the live route table). No client consumed the old paths, so this is not a breaking change in practice. - Wired four previously-defined-but-orphaned `AuthController` endpoints into `Application::loadApiRoutes()` (Section 1.6a). Each handler existed on the controller but had no route, so requests 404'd: `POST /api/v1/auth/register`, `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `GET /api/v1/auth/me`. The `me` endpoint relies on `$request->userId` being populated by upstream auth middleware (same convention as `/api/v1/me/continue-watching`). - Replaced the stale `// Placeholder for API routes - will be populated in later phases` comment at the top of `Application::loadApiRoutes()` — the method already wires ~40 routes today. New comment describes the actual API surface (auth, sessions, media, WebAuthn, DLNA/Chromecast/AirPlay/Roku, admin) and points readers at `src/Server/Http/Controllers/`. - Wired four previously-defined-but-orphaned `MarkerController` endpoints into `Application::loadApiRoutes()` (Section 1.6c). The handlers existed but had no route, so requests 404'd: `GET /api/v1/media/{id}/markers`, `GET /api/v1/media/{id}/markers/intro`, `GET /api/v1/media/{id}/markers/outro`, `GET /api/v1/shows/{id}/markers/bulk`. Resolves the controller from the PSR-11 container with a hand-wired fallback (matches the `getAuthController()` pattern). - Wired three previously-defined-but-orphaned `ExtrasController` endpoints into `Application::loadApiRoutes()` (Section 1.6c). The handlers existed but had no route, so requests 404'd: `GET /api/v1/media/{id}/extras`, `GET /api/v1/media/{id}/trailers`, `GET /api/v1/media/{id}/extras/other`. Resolves the controller from the PSR-11 container with a hand-wired fallback (matches the `getAuthController()` pattern); `MediaServicesProvider` now binds `TmdbProvider` to a factory that reads the API key from `$appConfig['tmdb']['api_key']` or the `TMDB_API_KEY` env var. - Added `config/tmdb.php` with a `getenv('TMDB_API_KEY')` default so operators can enable TMDB lookups without code changes. - **Operator action required:** Set `TMDB_API_KEY` environment variable to enable trailer fetching via the new ExtrasController routes. Without it, /api/v1/media/{id}/trailers and related endpoints return no results from TMDB (local extras cache still works). ### Added (post-O.7 wave 4, G.3) - Last.fm scrobble plugin (`src/Plugins/Scrobbler/Lastfm/`): - `LastfmApi` — Web Service v2 client. Builds `api_sig` per the official rule (alphabetical key+value concat + shared secret + MD5). - `LastfmSessionRepository` — per-user session-key store backed by the new `lastfm_sessions` table (migration `023_lastfm_sessions.sql`). - `LastfmScrobbler` — PSR-14 listener; subscribes to `phlix.playback.started` (Now Playing) and `phlix.playback.stopped` (scrobble). Enforces Last.fm's official rule: scrobble only when the track is longer than 30 s AND the user listened to more than 50 % of it. - `LastfmPlugin` — `\Phlix\Shared\Plugin\LifecycleInterface` entry class; resolves dependencies from the host container on `enable()` and exposes the scrobbler via `subscribedEvents()`. - `LastfmConfig` — typed wrapper over `config/lastfm.php`. New config keys default to `LASTFM_API_KEY`, `LASTFM_SHARED_SECRET`, `LASTFM_CALLBACK_URL`, `LASTFM_ENABLED` (env-driven). - Admin connect flow: `GET /admin/lastfm`, `GET /admin/lastfm/callback`, `POST /admin/lastfm/disconnect` (`Admin\LastfmController`) plus a Smarty template at `public/templates/admin/lastfm.tpl`. - New required env vars (only when enabling the plugin): `LASTFM_API_KEY`, `LASTFM_SHARED_SECRET`. Optional: `LASTFM_CALLBACK_URL`, `LASTFM_USERNAME`, `LASTFM_ENABLED`, `LASTFM_SUBMIT_NOW_PLAYING`. ### Moved (post-O.7 wave 4) - K.3 request UI: moved to phlix-hub (now lives at `/api/v1/me/requests` on the hub, with the admin queue at `/api/v1/admin/requests`). Server no longer exposes `/api/v1/requests`, `/requests` (SSR), `/requests/{id}`, or the `requests` table — those were dropped along with `migrations/016_media_requests.sql`. The hub stores requests against its own `users` table (hub migration `011_media_requests.sql`) and dispatches approvals through Sonarr/Radarr via `Phlix\Shared\Arr` v0.4.0. ### Changed (post-O.7 wave 3) - Helm chart fleshed out for both `phlix-server` and `phlix-hub` (values + templates: deployment, service, ingress, pvc, configmap, secret, serviceaccount, hpa, NOTES). - Caddyfile WebSocket headers fixed (`Connection: upgrade` / `Upgrade: websocket` — previously inverted). - nginx `/media/` location now uses `proxy_request_buffering off` so large client uploads stream through; sensitive-path deny rule tightened to `^/+(...)(/|$)` to defeat double-slash bypass. - Dockerfile `composer install` no longer suffixed with `|| true` — composer failures now fail the build (default Alpine variant + NVIDIA/Intel HW-accel variants). Path-layout rationale documented in `docker/README.md`. - CI: added `phlix-hub` build/push job in `.github/workflows/docker.yml`. - CI: `.github/workflows/release.yml` now verifies `Chart.yaml` `appVersion` and `composer.json` `version` match the release tag, lints + packages charts, and uploads them with the release. ### Removed - **`src/Chromecast/RemoteCastClient.php` (and its test)** — dead code with zero callers. It was premised on a server-initiated *outbound* "cast over relay" channel that the hub relay does not provide: the relay (`RelayConsumer`) pipes *inbound* client connections to the local HTTP server, so a remote client reaching this server through the hub already lands on the normal `/api/v1/cast/devices/{id}/*` routes, which drive the device via the local `CastApiClient`. Remote casting therefore works through the relay's HTTP pipe with no dedicated client; the throwing `RemoteCastClient` stub was redundant and has been removed. - `SESSION_HANDOFF.md` (commit 9758a1b, message "upate"): obsolete handoff scratchpad no longer referenced anywhere. No functional change. ### Added (Step L.1) - Webhook plugin framework for sending events to HTTP endpoints: - `WebhookEvent` — event class with eventType, payload, occurredAt, toArray(), getSignature() using HMAC-SHA256 - `WebhookDispatcher` — registers/unregisters/dispatches webhooks, uses Workerman\MySQL\Connection and Workerman\Timer for async dispatch - `DispatchResult` — result class with successCount, failureCount, failures - `WebhookPluginInterface` — interface with getName(), getSupportedEvents(), send() - `migrations/018_webhooks.sql` — webhooks and webhook_logs tables - `WebhookAdminController` — GET/POST/DELETE /api/v1/admin/webhooks, POST test endpoint - `config/webhooks.php` — configuration with enabled, timeout, max_retries, parallel_dispatch - Unit tests: `WebhookEventTest` (5 tests), `WebhookDispatcherTest` (7 tests) ### Added (Step L.2) - Notification provider plugins for webhook events: - 7 plugins: Discord, Slack, Telegram, Ntfy, Pushover, Apprise, MQTT - `AbstractNotificationPlugin` — base class with formatMessage(), getEmbedColor() - `WebhookPluginInterface` — getName(), getSupportedEvents(), send() - `PluginRegistry` — plugin management with get(), listAll(), register() - `config/notifications.php` — all 7 provider configurations - Unit tests: DiscordPluginTest (7), SlackPluginTest (6), TelegramPluginTest (6), NtfyPluginTest (7) ### Added (Step L.3) - Stats collection system for tracking playback, library changes, user activity, and storage: - `migrations/019_stats_schema.sql` — 4 tables: stats_playback_events, stats_library_changes, stats_user_activity, stats_storage - `StatsCollector` — service with recordPlaybackStart/End, recordLibraryChange, recordUserActivity, recordStorageSnapshot, getPlaybackStats, getTopUsers, getTopMedia - `StatsController` — admin API: GET /api/v1/admin/stats/playback, top-users, top-media, storage - `PlaybackController` integration — calls StatsCollector on play start/end - Unit tests: `StatsCollectorTest` (7 tests) ### Added (Step L.4) - Admin dashboard with real-time now playing, top users/media leaderboards, storage summary, and recent activity feed: - `DashboardService` — aggregation service with getNowPlaying(), getTopUsers(), getTopMedia(), getStorageSummary(), getRecentActivity() - `DashboardController` — admin API: GET /api/v1/admin/dashboard/now-playing, top-users, top-media, storage, activity - `DASHBOARD_NOW_PLAYING` WebSocket event for live updates - `subscribe_dashboard` WebSocket handler to send current now-playing state - `public/templates/admin/dashboard.tpl` — Smarty template with Now Playing grid, Top Users/Media tables, Storage usage, Activity feed - `PageRenderer::renderDashboard()` — renders dashboard page - `/admin/dashboard` route in `public/index.php` - Unit tests: `DashboardServiceTest` (5 tests) ### Added (Step L.5) - Weekly newsletter email system for user engagement: - `migrations/020_newsletter.sql` — newsletter_queue table with id, user_id, week_start, status, attempts, last_attempt_at, sent_at, error_message - `config/newsletter.php` — configuration with enabled, send_day, send_hour, batch_size, from_email, from_name, subject_template - `NewsletterGenerator` — generates email content with watch time, top media, new items using Smarty template - `NewsletterSender` — queues and processes newsletter delivery with batch processing and retry logic - `public/templates/emails/newsletter.tpl` — responsive HTML email template with watch summary, top 5 media, new items, CTA button, unsubscribe link - `Application::startNewsletterTimerIfEnabled()` — Workerman Timer integration for scheduled newsletter delivery - Unit tests: `NewsletterGeneratorTest` (4 tests), `NewsletterSenderTest` (5 tests) ### Added (Step L.6) - Server backup and restore system with local storage, S3-compatible cloud backup, and automatic scheduling: - `migrations/021_backups.sql` — backups table with id, label, file_path, size_bytes, checksum_sha256, is_s3, created_at, expires_at - `config/backup.php` — configuration with enabled, local_path, retention_count, auto_backup_interval_days, s3 settings - `RestoreResult` — result class with success, message, error properties - `S3Client` — minimal S3-compatible client using AWS Signature V4 for upload, download, listObjects, deleteObject - `BackupManager` — backup creation with mysqldump + tar.gz, restore with checksum verification, S3 upload/download, retention management - `BackupController` — 7 admin API endpoints: POST create, GET list, DELETE delete, POST restore, POST upload-s3, GET/PUT schedule - `Application::startBackupTimerIfEnabled()` — Workerman Timer integration for scheduled backups - Unit tests: `BackupManagerTest` (11 tests), `S3ClientTest` (10 tests) ### Added (Step K.2) - Bazarr/Prowlarr API clients for subtitle and indexer management: - `BazarrClient` — Bazarr API client with getSubtitles(), getSubtitleLanguages(), downloadSubtitle(), getLanguages(), testConnection() - `ProwlarrClient` — Prowlarr API client with getIndexers(), getIndexerStats(), getHealth(), triggerReindexerCheck(), testConnection() - Extended `config/arr.php` with bazarr and prowlarr sections - Unit tests: `BazarrClientTest` (9 tests), `ProwlarrClientTest` (8 tests) ### Added (Step K.1) - Sonarr/Radarr API clients for media server integration: - `ArrClientInterface` — common interface for *arr clients with getQueue(), getQualityProfiles(), getTagList(), testConnection() - `SonarrClient` — Sonarr v3 API client with getSeries(), getSeriesById(), getEpisodeFile(), getQueue(), getWantedMissing(), getQualityProfiles(), getTagList(), addSeries(), triggerDownload(), testConnection() - `RadarrClient` — Radarr v3 API client with getMovies(), getMovieById(), getQueue(), getQualityProfiles(), getCustomFormats(), getTagList(), addMovie(), triggerDownload(), testConnection() - `ArrClientFactory` — factory for creating Sonarr/Radarr clients from config array - `config/arr.php` — configuration file for Sonarr/Radarr connection settings - Unit tests: `SonarrClientTest` (12 tests), `RadarrClientTest` (11 tests), `ArrClientFactoryTest` (10 tests) ### Added (Step J.6) - Roku ECP support — send media to Roku devices: - `RokuDevice` — Roku device descriptor with deviceId, name, host, port, model, softwareVersion - `RokuDiscovery` — discovers Roku devices via mDNS `_ roku-ecnp._tcp.local.` using MdnsDiscovery - `RokuEcpClient` — HTTP ECP client with launchChannel(), playMedia(), sendKeypress(), getDeviceInfo(), getPlayerState() - `RokuSession` — active Roku session with playMedia()/pause()/play()/stop(), player state polling every 5 seconds via Workerman Timer - `RokuManager` — manages Roku sessions, discovers devices, creates sessions, launches media - `RemoteRokuClient` — Roku control via relay tunnel (RelayConsumer) for devices behind NAT - `RokuController` — HTTP API endpoints: - GET /api/v1/roku/devices — list discovered Roku devices - POST /api/v1/roku/devices/{id}/send — send media to Roku - POST /api/v1/roku/devices/{id}/launch/{channelId} — launch a channel - POST /api/v1/roku/devices/{id}/key/{keyName} — send keypress - GET /api/v1/roku/devices/{id}/status — get session status - `Application` — registered Roku routes in `loadRokuRoutes()` - Unit tests: `RokuDeviceTest` (4 tests), `RokuDiscoveryTest` (3 tests), `RokuEcpClientTest` (8 tests), `RokuSessionTest` (7 tests), `RokuManagerTest` (6 tests) ### Added (Step J.5) - AirPlay 2 support — stream audio to AirPlay 2 devices (Apple TV, HomePod, AirPlay 2-compatible receivers): - `AirPlayDevice` — AirPlay device descriptor with deviceId, name, host, port, raopPort, model, supportsVideo - `AirPlayDiscovery` — discovers AirPlay devices via mDNS `_airplay._tcp.local.` and `_raop._tcp.local.` using MdnsDiscovery - `RaopClient` — RAOP (Real-Time Audio Protocol) client with buildAnnouncePayload(), flush(), getRtpInfo(), getLatency() - `AirPlaySession` — active AirPlay session with startStream()/pause()/resume()/stop() and state management - `AirPlayManager` — manages AirPlay sessions, discovers devices, creates/retrieves/stops sessions - `RemoteAirPlayClient` — AirPlay via relay tunnel (RelayConsumer) for devices behind NAT - `AirPlayController` — HTTP API endpoints: - GET /api/v1/airplay/devices — list discovered AirPlay devices - POST /api/v1/airplay/devices/{id}/stream — start streaming - POST /api/v1/airplay/devices/{id}/pause — pause playback - POST /api/v1/airplay/devices/{id}/resume — resume playback - POST /api/v1/airplay/devices/{id}/stop — stop playback - GET /api/v1/airplay/devices/{id}/status — get session status - `HlsStreamer` — added `getAirPlayStreamUrl()` for AirPlay-compatible stream URLs - `Application` — registered AirPlay routes in `loadAirPlayRoutes()` - Unit tests: `AirPlayDeviceTest` (5 tests), `AirPlayDiscoveryTest` (3 tests), `RaopClientTest` (5 tests), `AirPlaySessionTest` (5 tests), `AirPlayManagerTest` (5 tests) ### Added (Step J.4) - Chromecast support — cast to Chromecast devices via Default Media Receiver: - `CastDevice` — Chromecast device descriptor with device ID, name, host, port, model, UUID - `CastDiscovery` — discovers Chromecast devices via mDNS `_googlecast._tcp.local.` using MdnsDiscovery - `CastApiClient` — HTTP/JSON Cast protocol client with connect(), launchApp(), loadMedia(), sendMediaCommand(), getMediaStatus() - `CastSession` — active Chromecast session with play/pause/stop/seek, position polling every 5 seconds via Workerman Timer - `CastManager` — manages multiple cast sessions, creates sessions, launches app, loads media - `RemoteCastClient` — cast via relay tunnel (RelayConsumer) for Chromecast behind NAT (in progress / not operational — depends on a hub relay-tunnel feature that does not exist yet; the client throws `RuntimeException` rather than silently faking success) - `ChromecastController` — HTTP API endpoints: - GET /api/v1/cast/devices — list discovered Chromecast devices - POST /api/v1/cast/devices/{id}/cast — start casting - POST /api/v1/cast/devices/{id}/play — resume playback - POST /api/v1/cast/devices/{id}/pause — pause playback - POST /api/v1/cast/devices/{id}/stop — stop casting - POST /api/v1/cast/devices/{id}/seek — seek to position (ms) - GET /api/v1/cast/devices/{id}/status — get session status - `HlsStreamer` — added `getCastStreamUrl()` for Chromecast-compatible stream URLs - `Application` — registered Chromecast routes in `loadChromecastRoutes()` - Default Media Receiver app ID: `CC1AD845` - Unit tests: `CastDeviceTest` (4 tests), `CastDiscoveryTest` (4 tests), `CastApiClientTest` (8 tests), `CastSessionTest` (8 tests), `CastManagerTest` (8 tests) ### Added (Step J.3) - DLNA AVTransport "play to" — send media to DLNA renderers: - `RendererDiscovery` — discovers DLNA MediaRenderers via SSDP with `urn:schemas-upnp-org:device:MediaRenderer:1` - `RendererControlClient` — HTTP SOAP client for AVTransport control (SetAVTransportURI, Play, Pause, Stop, Seek, GetPositionInfo, GetTransportInfo) - `PlayToSession` — active "play to" session with position polling every 5 seconds via Workerman Timer - `PlayToManager` — manages multiple play-to sessions, creates RendererControlClient, maps renderer IDs to sessions - `RemoteRendererClient` — "play to" via relay tunnel (RelayConsumer) for renderers behind NAT - `RendererListController` — HTTP API endpoints: - GET /api/v1/dlna/renderers — list discovered renderers - POST /api/v1/dlna/renderers/{id}/play — start "play to" session - POST /api/v1/dlna/renderers/{id}/pause — pause playback - POST /api/v1/dlna/renderers/{id}/stop — stop playback - POST /api/v1/dlna/renderers/{id}/seek — seek to position (ticks) - GET /api/v1/dlna/renderers/{id}/status — get renderer state - `AvTransport` — added `onStateChange()` callbacks and `notifyStateChange()` for observable state changes - `PlaybackController` — added `startPlayToSession()` for integrated local + remote playback - `Application` — registered DLNA renderer control routes in `loadDlnaRendererRoutes()` - Unit tests: `RendererDiscoveryTest` (5 tests), `RendererControlClientTest` (9 tests), `PlayToSessionTest` (11 tests), `PlayToManagerTest` (8 tests) ### Added (Step J.2) - DLNA ContentDirectory full — browse and search real media library: - `LibraryBridge` — bridges `ItemRepository` to `ContentDirectory` for real media data - `CdsControlHandler` — HTTP SOAP endpoint for ContentDirectory actions (Browse, Search) - `CdsServer` — full DLNA MediaServer with HTTP endpoints: `/description.xml`, `/cds/control`, `/scpd/{service}.xml` - `src/Server/Http/Controllers/Dlna/DeviceDescriptionController` — serves `/description.xml` - `src/Server/Http/Controllers/Dlna/CdsControlController` — handles CDS SOAP requests - `ContentDirectory` — now uses `LibraryBridge` for real library data instead of stubs - `DlnaServer` — requires real `ItemRepository` (no stub), supports `setLibraryBridge()` - Unit tests: `LibraryBridgeTest` (14 tests), `CdsControlHandlerTest` (10 tests), `CdsServerTest` (13 tests) ### Added (Step J.1) - SSDP (Simple Service Discovery Protocol) and mDNS (multicast DNS) discovery infrastructure: - `SsdpSocket` — raw UDP socket wrapper for SSDP multicast `239.255.255.250:1900` - `SsdpDevice` — discovered SSDP device descriptor with `getDeviceId()` and `getBaseUrl()` - `SsdpDiscovery` — SSDP discovery service with `discoverDevices()` and `announceServer()` - `MdnsSocket` — raw UDP socket wrapper for mDNS multicast `224.0.0.251:5353` - `MdnsService` — resolved mDNS service descriptor with `getAddress()` - `MdnsDiscovery` — mDNS discovery service with `discoverChromecast()`, `discoverAirPlay()`, `discoverRoku()` - `DiscoveryManager` — unified facade combining SSDP and mDNS discovery - `DiscoveryServer` — Workerman Timer integration for background discovery - `config/discovery.php` — configuration with SSDP/mDNS settings - Unit tests: `SsdpSocketTest`, `SsdpDiscoveryTest`, `MdnsSocketTest`, `MdnsDiscoveryTest`, `DiscoveryManagerTest` (12+ tests) - `docs/developers/discovery.md` — protocol documentation ### Added (Step I.7) - Hub relay for remote live TV streams (HLS re-streaming via hub WebSocket tunnel): - `HlsRelaySession` — value object for relay session with `sessionId`, `channelId`, `tuneRequestId`, `getMountUrl()`, `getVariantPlaylistUrl()` - `HlsRelayManager` — orchestrates relay sessions: `startRelaySession()`, `stopRelaySession()`, `getActiveSessions()`, `getUserSession()` - `HlsSegmentPrefetcher` — LRU cache for HLS segments with Workerman Timer-based prefetching (`startPrefetch()`, `stopPrefetch()`, `getSegment()`) - `HlsRelaySessionFactory` — factory for building `HlsRelayManager` from config - `RelayConsumer` — added `registerMount()` and `unregisterMount()` methods for dynamic path handlers; `dispatchViaMount()` routes `/relay/live/{sessionId}/*` to registered handlers - `migrations/015_livetv_relay_sessions.sql` — creates `livetv_relay_sessions` table - `config/livetv.php` — added `relay` section with `enabled`, `prefetch_segments`, `max_concurrent_sessions`, `segment_cache_ttl_seconds`, `relay_path_prefix` - Unit tests in `tests/Unit/LiveTv/Relay/` (HlsRelaySessionTest, HlsRelayManagerTest, HlsSegmentPrefetcherTest — 26+ tests) - `docs/developers/live-relay.md` — architecture docs, session lifecycle, configuration ### Added (Step I.6) - Comskip commercial detection for live TV recordings with chapter markers: - `ComskipIntegration` — wires `ComskipRunner` into recording lifecycle: `processRecording()`, `getEdlSegments()`, `markProcessed()` - `ComskipLifecycleManager` — queue management with `max_concurrent` enforcement: `enqueue()`, `processNext()`, `getPendingCount()` - `ChapterMarkerService` — EDL to HLS chapter conversion: `toHlsChapters()`, `persistChapters()`, `getChapters()` - `migrations/014_livetv_commercials.sql` — adds `commercial_processed_at`, `commercial_edl_path`, `commercial_frame_count`, `commercial_duration_seconds` to `livetv_recordings` - `config/livetv.php` — added `comskip` section with `enabled`, `binary_path`, `ini_path`, `output_dir`, `queue_processing`, `max_concurrent` - `Recorder` — registers `ComskipLifecycleManager::enqueue()` via `onComplete()` callback at construction time - Unit tests in `tests/Unit/LiveTv/Recording/` (ComskipIntegrationTest, ComskipLifecycleManagerTest, ChapterMarkerServiceTest — 12+ tests) - `docs/developers/comskip-live.md` — integration docs, EDL format, config ### Added (Step I.5) - Scheduled + series DVR recordings. Includes: - `SeriesRuleManager` — CRUD for series recording rules; `matchAndSchedule()` queries `GuideManager::getUpcomingBySeries()` and schedules unmatched episodes - `RecordingDeduplicator` — prevents duplicate recordings via 2-hour window; `isDuplicate()`, `getCanonical()`, `resolveDuplicates()` - `RecordingScheduler` — priority-based conflict resolution; `processDueRecordings()` runs via Workerman timer; `getNextRecording()` for display - `RecordingHooksRunner` — async post-recording hook enqueueing - `migrations/013_livetv_dvr.sql` — adds `series_rule_id`, `duplicate_group`, `pre/post_padding_seconds` to `livetv_recordings`; creates `livetv_series_rules` table - `Recorder` — updated `scheduleRecording()` accepts `pre_padding_seconds`, `post_padding_seconds`, `series_rule_id`; added `isDuplicate()` method; `startRecording()` applies pre-padding (starts recording early) - `config/livetv.php` — added `dvr` section with `default_pre_padding_seconds`, `default_post_padding_seconds`, `auto_resolution`, `storage_path`, `max_storage_bytes` - `RecordingHooks` — already wires `ComskipPostProcessor` via `onComplete()` callback - Unit tests in `tests/Unit/LiveTv/Recording/` (SeriesRuleManagerTest, RecordingDeduplicatorTest, RecordingSchedulerTest — 12+ tests) - `docs/developers/dvr.md` — series rules, deduplication, padding, conflict resolution, scheduler integration ### Added (Step I.4) - Schedules Direct EPG integration. Includes: - `SdApiClient` — HTTP JSON client for SD API with token auth (BASE_URL: https://api.schedulesdirect.tmsglobal.com) - `SdLineupHandler` — fetches SD lineups, imports channels via ChannelManager - `SdProgramMapper` — maps SD program/schedule data to GuideManager format - `SdEpgService` — orchestrates full sync: fetch schedules, programs, upsert to guide - `SdEpgServiceFactory` — builds service from config with token caching - `config/livetv.php` — added `schedules_direct` section (username, password, token_cache_path, lineup_id, sync_hours_ahead, timeout_secs) - `LiveTvManager` — wired `SdEpgService` as optional dependency; `getSdEpgService()`, `setSdConfig()`, `syncSdEpG()` - Unit tests in `tests/Unit/LiveTv/Epg/SchedulesDirect/` (SdApiClientTest, SdProgramMapperTest, SdEpgServiceTest — 12 tests total) - `docs/developers/schedules-direct.md` — SD API overview, auth, endpoints, data model, and config reference ### Added (Step I.3) - Linux DVB-T USB tuner driver. Includes: - `DvbtDevice` — immutable value object for /dev/dvb/ devices - `DvbtDeviceScanner` — scans /dev/dvb/ for adapters, reads capabilities - `DvbtSignalEngine` — dvbv5-zap integration + FFmpeg ingest URL generation - `DvbtTunerDriver` — implements `TunerDriverInterface` - `DvbtTunerDriverFactory` — builds driver from `config/livetv.php` - `config/livetv.php` — added `dvbt` section - `TunerDriverInterface` — updated to accept `DvbtDevice` union type - `LiveTvManager` — integrated DvbtTunerDriver via additionalDrivers - Unit tests for scanner, signal engine, and driver - `docs/developers/dvbt.md` — developer documentation ### Added (Step I.2) - M3U/XMLTV IPTV tuner driver. Includes: - `M3UEntry` — immutable value object for M3U playlist entries - `M3UParser` — parses M3U/M3U8 playlists, fetches remote via `parseUrl()` - `XmlTvProgramme` — immutable value object for XMLTV programme entries - `XmlTvParser` — parses XMLTV format, handles YYYYMMDDHHMMSS times - `IptvDevice` — immutable descriptor for IPTV sources - `IptvTunerDriver` — implements `TunerDriverInterface` for IPTV - `IptvTunerDriverFactory` — builds driver from `config/livetv.php` - `config/livetv.php` — added `iptv` section with `sources` array - `LiveTvManager` — integrated IPTV alongside HDHomeRun tuners - `GuideManager::upsertProgram()` — added `xmltv_id` parameter for IPTV matching - Unit tests for `M3UParser`, `XmlTvParser`, `IptvTunerDriver` - `docs/developers/iptv.md` — developer documentation ### Added (Step I.1) - HDHomeRun tuner driver (SSDP discovery + HTTP API). Includes: - `TunerDriverInterface` — shared interface for all tuner drivers - `HdHomeRunDevice` — immutable value object for discovered devices - `HdHomeRunDiscovery` — SSDP M-SEARCH discovery on UDP 1900 - `HdHomeRunApiClient` — HTTP API client for HDHomeRun devices - `HdHomeRunTunerDriver` — concrete driver implementing `TunerDriverInterface` - `HdHomeRunTunerDriverFactory` — factory for driver instantiation - `LiveTvManager` refactored to use `TunerDriverInterface` (no more `/dev/dvb` references) - `config/livetv.php` — LiveTV configuration with HDHomeRun settings - Unit tests for `HdHomeRunDiscovery`, `HdHomeRunApiClient`, `HdHomeRunTunerDriver` - `docs/developers/hdhomerun.md` — developer documentation ### Added (Step H.6) - Theme music + theme video auto-play on browse. Includes: - `ThemeAudio` — readonly DTO (path, url, duration, format) for audio themes - `ThemeVideo` — readonly DTO (path, url, duration, width, height, format) for video backdrops - `ThemeMedia` — readonly DTO containing libraryId, audio, video, scannedAt - `ThemeMediaFinder` — filesystem scanner for theme.mp3/theme.ogg and backdrop.mp4/backdrop.webm - `ThemeMediaRepository` — cache operations (upsert, findByLibraryId, delete) - `ThemeMediaController` — 3 REST endpoints: - `GET /api/v1/libraries/{id}/theme-media` — get theme media - `POST /api/v1/libraries/{id}/theme-media/scan` — trigger rescan - `DELETE /api/v1/libraries/{id}/theme-media` — clear cached entry - `ThemeMediaStreamController` — 2 streaming endpoints: - `GET /stream/theme-media/{libraryId}/audio` — stream theme audio - `GET /stream/theme-media/{libraryId}/video` — stream theme video - `Migration 008_theme_media.sql` — creates theme_media table - `Router::themeMedia()` — registers all theme media routes - `library-header.tpl` — theme media player partial with toggle button - `theme-media.js` — autoplay handling with browser policy fallback - `LibraryManager::scanThemeMedia()` — scans and caches after library scan - `PageRenderer::setThemeMediaRepository()` + `renderLibrary()` passes themeMedia to template - Unit tests in `tests/Unit/Theming/` (10+ tests) - Integration test `tests/Integration/Theming/ThemeMediaScanTest.php` - `docs/developers/theme-media.md` — file naming, scanning, streaming, autoplay policy ### Added (Step H.5) - Trailers and extras with local `Trailers/` folder support. Includes: - `Trailer` — readonly DTO (id, mediaItemId, title, source, url, duration, quality, isLocal, filePath) - `Extra` — readonly DTO for non-trailer extras (featurette|behind_the_scenes|interview|clip|deleted_scene|trailer) - `TrailerFinder` — filesystem scanner for local trailers (same-level and Trailers/ subfolder) - `TrailerResolver` — merges local + TMDB trailers, caches in media_extras with 24h TTL - `ExtrasRepository` — data access for media_extras table - `ExtrasController` — 3 REST endpoints: - `GET /api/v1/media/{id}/extras` — full merged list - `GET /api/v1/media/{id}/trailers` — trailers only - `GET /api/v1/media/{id}/extras/other` — non-trailer extras - `Migration 007_media_extras.sql` — creates media_extras table - `TmdbProvider::getTrailers()` — fetches trailers from TMDB API - `Router::extras()` — registers ExtrasController routes - `MediaScanner::hasTrailers()` — detects Trailers/ folders at scan time - `FolderWatcher::shouldRescanExtras()` — triggers extras rescan on change - Unit tests in `tests/Unit/Media/Extras/` (15 tests) - Integration test `tests/Integration/Media/Extras/TrailerScannerTest.php` - `docs/developers/trailers-and-extras.md` — naming conventions, API reference, architecture ### Added (Step H.4) - Trakt.tv scrobble plugin with two-way history sync. Includes: - `TraktApi` — OAuth2 PKCE client, scrobble start/pause/stop, history sync - `TraktSettings` — per-user settings (tokens, sync prefs, username) - `TraktPlugin` — LifecycleInterface entry, subscribes to PlaybackStarted/Stopped/ProgressUpdated - `TraktHistorySync` — syncTraktToPhlix() (pull on schedule) and syncPhlixToTrakt() (push on ≥90% completion) - `TraktOAuthController` — OAuth callback at GET /api/v1/oauth/trakt/callback - `config/scrobblers/trakt.php` — client_id, client_secret, redirect_uri, sync_interval - `phlix-plugin-trakt/plugin.json` — scrobbler plugin manifest - Unit tests (19 tests across TraktApi, TraktSettings, TraktHistorySync, TraktPlugin) - `docs/developers/scrobbler-plugins.md` — scrobbler plugin author guide - New Router method `traktAuth()` for Trakt OAuth routes ### Added (Step H.3) - Custom CSS / themes with `ui-theme` plugin type. Includes: - `Theme` — readonly theme descriptor (id, name, type, cssUrl, jsUrl, thumbnailUrl, version, pluginName, dark). - `ThemeRegistry` — central registry with registerBuiltIn(), registerFromPlugin(), getTheme(), getAllThemes(), getActiveThemeForUser(), setActiveThemeForUser(). - `ThemeMiddleware` — HTTP middleware that injects theme CSS/JS into WebPortal responses via str_replace on Smarty placeholders. - `ThemePluginInterface` — marker interface for ui-theme plugin entry classes. - `ThemePreviewController` — renders live theme preview in iframe sandbox at GET /portal/theme-preview?id={themeId}. - `config/themes.php` — 4 built-in themes (phlix-dark, phlix-light, phlix-amoled, phlix-contrast) with CSS and thumbnail assets. - Migration `migrations/006_user_theme_settings.sql` — adds active_theme_id to user_profiles. - UserProfileManager::getActiveThemeId() / setActiveThemeId() for per-profile theme preferences. - `{$theme_css|raw}` and `{$theme_js|raw}` Smarty placeholders in base.tpl. - `var/themes/` runtime directory for extracted plugin themes (gitignored). - Unit tests in `tests/Unit/Theming/` (ThemeRegistryTest, ThemeMiddlewareTest — 11 tests). - `docs/developers/ui-themes.md` — plugin author guide with CSS variable reference. ### Added (Step H.2) - Collections — named groups of media items for manual curation (bulk-add from search) and rule-based auto-population via smart playlists. Includes: - `Collection` — readonly entity with id, name, libraryId, smartPlaylistId, parentId, sortOrder, timestamps. - `CollectionWithItems` — hydrated DTO with collection + hydrated media items. - `CollectionRepository` — full CRUD for collections table with parameterized Workerman\MySQL\Connection queries. - `CollectionItemRepository` — membership CRUD for collection_items with sort order support. - `CollectionManager` — orchestrator with addItem(), removeItem(), bulkAddFromSearch(), getCollectionWithItems(), refreshSmartCollection(). - `CollectionController` — 9 REST API endpoints: GET/POST /api/v1/collections, GET/PUT/DELETE /api/v1/collections/{id}, POST/DELETE /api/v1/collections/{id}/items/{mediaItemId}, POST /api/v1/collections/{id}/bulk-add, POST /api/v1/collections/{id}/refresh, GET /api/v1/libraries/{libraryId}/collections. - Migration `migrations/005_collections.sql` — creates collections and collection_items tables with proper indexes. - Unit tests in `tests/Unit/Collections/` (CollectionRepositoryTest, CollectionItemRepositoryTest, CollectionManagerTest — 14 tests). - Integration test `tests/Integration/Collections/CollectionCrudTest.php`. - `docs/developers/collections.md` — model, API reference, smart sync algorithm, integration guide. - `Router::collections()` — registers collection routes. - `SmartPlaylistRefreshHandler` now calls CollectionManager::refreshSmartCollection() for any collection linked to a changed smart playlist. ### Added (Step H.1) - Smart-playlist rule engine with JSON DSL evaluation at scan time and on folder-watch events. Includes: - `RuleNode` — immutable AST node (TYPE_AND/OR/NOT/RULE) for rule trees. - `RuleOperators` — 11 static operator methods (equals, notEquals, contains, notContains, greaterThan, lessThan, between, in, notIn, startsWith, endsWith). - `SmartPlaylistEngine` — buildFromDsl(), evaluate(), evaluateOnScan(), toJson() for parsing JSON DSL and evaluating media items against rules. - `SmartPlaylist` — readonly entity with id, name, libraryId, rulesJson, limit, sortBy, sortDesc, timestamps. - `SmartPlaylistRepository` — full CRUD for smart_playlists table with parameterized Workerman\MySQL\Connection queries. - `SmartPlaylistRefreshHandler` — listens to LibraryUpdated events and re-evaluates all smart playlists for the changed library. - `SmartPlaylistController` — REST API endpoints: GET/POST/PUT/DELETE /api/v1/smart-playlists, POST /api/v1/smart-playlists/{id}/preview. - `LibraryUpdated` event dispatched by FolderWatcher on content changes. - Migration `migrations/004_smart_playlists.sql` — creates smart_playlists table with JSON rules column, limit, sort_by, sort_desc fields. - Unit tests in `tests/Unit/Playlists/` (RuleNodeTest, RuleOperatorsTest, SmartPlaylistEngineTest, SmartPlaylistRepositoryTest, SmartPlaylistTest). - Integration test `tests/Integration/Playlists/SmartPlaylistRefreshTest.php`. - `docs/developers/smart-playlists.md` — DSL reference, operator list, evaluation algorithm, extension guide. - `Router::smartPlaylists()` — registers smart playlist routes. - `FolderWatcher` now injects EventDispatcherInterface and dispatches LibraryUpdated events when changes are detected. - MediaServicesProvider registers SmartPlaylistEngine, SmartPlaylistRepository, SmartPlaylistRefreshHandler, SmartPlaylistController. ### Added (Step G.6) - `AudiobookProgress` — Value object for per-user audiobook progress tracking. Immutable with position_ms, current_chapter_index, completed_chapters array, percent_complete, and last_position_ms for chapter-resume support. - `AudiobookProgressStore` — Persistence layer using Workerman MySQL for audiobook_progress table. Supports getProgress(), saveProgress(), and markChapterComplete() operations with composite PK (user_id, audiobook_id). - `AudiobookScanner` — Extends BookScanner for audiobook-specific scanning. - `harvestChapters()` — Pure-PHP M4B chapter extraction via MP4 chpl atom parsing (binary string scanning, no external dependencies). Handles 64-bit duration values. - Returns chapters as metadata_json array with title, start_ms, end_ms, and duration_ms fields. - `AudiobookLibraryManager` — Extends BookLibraryManager for audiobook libraries. Orchestrates scanning and progress management. Methods: getProgress(), saveProgress(), markChapterComplete(), chapterDuration(). - `AudiobookController` — REST API endpoints for audiobooks: - `GET /api/v1/audiobooks` — List audiobooks with pagination - `GET /api/v1/audiobooks/{id}` — Get audiobook details with chapters - `GET /api/v1/audiobooks/{id}/chapters` — List chapters for an audiobook - `GET /api/v1/audiobooks/{id}/progress` — Get user's progress for an audiobook - `POST /api/v1/audiobooks/{id}/progress` — Save progress (position, chapter) - `GET /api/v1/audiobooks/{id}/stream` — Stream audiobook (chapter + offset) - `AudiobookLibraryType` — Library type plugin with type `'audiobook'`. Returns AudiobookScanner and AudiobookLibraryManager instances. - Migration `012_audiobook_progress.sql` — Creates audiobook_progress table with user_id, audiobook_id, position_ms, current_chapter_index, completed_chapters (JSON), percent_complete, last_position_ms, created_at, updated_at. - Smarty templates: `audiobooks/audiobooks.tpl`, `audiobooks/audiobook.tpl`, `player/player.tpl`, `audiobooks/partials/audiobook_card.tpl`, `audiobooks/partials/chapter_row.tpl` — Audiobook grid view, detail with chapter navigation, audio player UI, and chapter list component. - `public/assets/css/audiobooks.css` — Player styles (play/pause, seek bar, volume, chapter list) and grid layout with cover cards. - `public/assets/js/audiobook-player.js` — Chapter navigation, progress persistence every 10 seconds, chapter completion tracking, play/pause controls. - `docs/libraries/audiobooks.md` — Documentation for supported formats (M4B, M4A, MP3), chapter navigation, progress persistence, and streaming. - Unit tests: AudiobookScannerTest (8 tests), AudiobookProgressStoreTest (4 tests), AudiobookLibraryManagerTest (4 tests), AudiobookControllerTest (9 tests). - Router now registers `/api/v1/audiobooks/*` routes. - LibraryManager routes `'audiobook'` type libraries through AudiobookScanner. ### Added (Step G.5) - `BookScanner` — Pure-PHP book file scanner for EPUB, PDF, and CBZ formats. - `harvestEpub()` — parses EPUB container.xml and content.opf for Dublin Core metadata (title, author, publisher, ISBN, language, pub_date, description) and extracts cover images. - `harvestPdf()` — uses `exif_read_data()` for XMP/EXIF metadata and pure-PHP page count extraction. - `harvestCbz()` — parses ComicInfo.xml for extended metadata (series, volume, authors, page_count) and extracts cover images from ZIP archive. - `scanBookLibrary()` — generator that yields book item arrays with metadata. - `BookLibraryManager` — orchestrates book library scanning, metadata extraction, and upsert. Implements `rescanLibrary()` for full pipeline and `upsertBook()` for single-file processing. - `BookLibraryType` — Library type plugin implementing `LibraryTypeInterface` with type `'book'`. Returns `BookScanner` and `BookLibraryManager` instances. - `OpdsFeedBuilder` — builds OPDS 1.2 compliant XML feeds using `DOMDocument`. - `buildRootFeed()` — root catalog with links to libraries. - `buildNavigationFeed()` — navigation feed listing book libraries. - `buildAcquisitionFeed()` — acquisition feed with pagination (?offset=N&limit=N). - `buildEntry()` — individual book entries with dc:title, dc:creator, opds:link rel=acquisition. - `BookController` — REST API endpoints for books and OPDS: - OPDS: `GET /opds/v1.2`, `GET /opds/v1.2/libraries`, `GET /opds/v1.2/libraries/{id}` - Web portal: `GET /books`, `GET /books/{id}`, `GET /books/{id}/cover`, `GET /books/{id}/read`, `GET /books/{id}/download` - Smarty templates: `books/books.tpl`, `books/book.tpl`, `books/reader.tpl`, `books/partials/book_card.tpl` — book grid view, book detail with cover and metadata, minimal reader stub, book card component. - `public/assets/css/books.css` — styles for book grid, cover cards, reader layout, and theme support (light/sepia/dark). - `public/assets/js/reader.js` — reader controller with font size controls, theme switching, keyboard navigation (←/→). - `docs/libraries/books.md` — documentation for supported formats, OPDS feed URL, third-party client setup (Uboiquity, Komga, Kore, Moon+ Reader), naming conventions, metadata fields, reader stub limitations. - `docs/reference/api.md` — updated with OPDS endpoints and Books API. - Unit tests: `BookScannerTest` (8 tests), `BookLibraryManagerTest` (2 tests), `OpdsFeedBuilderTest` (5 tests), `BookControllerTest` (7 tests). - Router now registers `/opds/*` and `/books/*` routes. - LibraryManager routes `'book'` type libraries through BookScanner. - WebPortalRouter now registers `/books` and `/books/{id}` routes. - `public/templates/partials/header.tpl` — Added Books nav link. - LibraryController accepts `'book'` as a valid library type. ### Added (Step G.4) - `PhotoScanner` — Pure-PHP photo file scanner with EXIF metadata extraction. Uses PHP's built-in `exif_read_data()` for JPEG files; graceful fallback for PNG/TIFF/WebP/HEIC. Extracts camera_make, camera_model, lens, aperture, iso, shutter_speed, focal_length, width, height, orientation, date_taken_unix, gps_lat, gps_lng, gps_alt. - `PhotoLibraryManager` — Orchestrates photo library scanning, EXIF extraction, and metadata upsert. Implements `rescanLibrary()` for full pipeline and `upsertPhoto()` for single-file processing. - `PhotoLibraryType` — Library type plugin implementing `LibraryTypeInterface` with type `'photo'`. Returns `PhotoScanner` and `PhotoLibraryManager` instances. - `ExifProvider` — Local EXIF metadata provider that reads from `metadata_json` stored on media items. Implements `MetadataProviderInterface`. - `PhotoController` — REST API endpoints for photo browsing and slideshow: - `GET /photo/albums` — list all albums (grouped by date) - `GET /photo/albums/{id}` — get specific album with photos - `GET /photo/photos` — list all photos - `GET /photo/photos/{id}` — photo with full EXIF data - `GET /photo/photos/{id}/thumbnail?w=300&h=300&fit=cover` — resized thumbnail - `GET /photo/photos/{id}/full` — full-resolution photo - `GET /photo/slideshow?album_id=xxx&interval=5` — slideshow data - Smarty templates: `photo/albums.tpl`, `photo/album.tpl`, `photo/photo.tpl`, `photo/slideshow.tpl`, `photo/partials/exif_panel.tpl`, `photo/partials/photo_card.tpl` — album grid, photo grid, lightbox view, fullscreen slideshow player, EXIF data sidebar. - `public/assets/css/photo.css` — Styles for album grid, photo grid, lightbox, EXIF sidebar, slideshow player. - `public/assets/js/slideshow.js` — Slideshow controller with auto-advance interval, keyboard nav (←/→/Space/Escape), touch/swipe support. - `docs/libraries/photos.md` — Documentation for supported formats, EXIF fields, album organization, API endpoints, thumbnail generation, slideshow features, and deferred geotag clustering note. - Unit tests: `PhotoScannerTest` (12 tests), `PhotoLibraryManagerTest` (6 tests), `PhotoControllerTest` (11 tests). - Router now registers `/photo/*` routes pointing to `PhotoController`. - LibraryManager routes `'photo'` type libraries through `PhotoLibraryManager`. - `public/templates/layouts/main.tpl` — Added Photos nav link. ### Added (Step G.3) - `Phlix\Plugins\Lastfm\Plugin` — In-core Last.fm scrobbler plugin implementing the `scrobbler` plugin type. Subscribes to `phlix.playback.started` (Now Playing updates) and `phlix.playback.stopped` (scrobble submission). Off by default; configure `config/lastfm.php` with API credentials to enable. - `Phlix\Plugins\Lastfm\LastfmApiClient` — Last.fm API v1.2 client with HMAC-MD5 signing. Supports `auth.getMobileSession`, `track.scrobble`, and `track.updateNowPlaying` endpoints. - `Phlix\Plugins\Lastfm\ScrobbleData` — Immutable value object for scrobble submission (artist, track, timestamp, album, duration, MusicBrainz ID). - `Phlix\Plugins\Lastfm\NowPlayingData` — Immutable value object for Now Playing notifications. - `Phlix\Plugins\Lastfm\LastfmPluginNotConfiguredException` — Thrown when API key, secret, or session key is missing. - `Phlix\Plugins\Lastfm\LastfmScrobbleFailedException` — Thrown when Last.fm API returns an error on scrobble/Now Playing. - `config/lastfm.php` — Default configuration with `enabled` (default false), `api_key`, `api_secret`, `session_key`, `username`, `submit_now_playing` (default true), and `scrobble_threshold` (default 0.5 — scrobble after 50% of track). - `docs/plugins/developer-guide.md` — Added §14 documenting the `scrobbler` plugin type with Last.fm as the reference example. - `docs/developers/lastfm-plugin.md` — New developer guide covering Last.fm API protocol, HMAC-MD5 signing, mobile auth flow, scrobble threshold semantics, and full configuration reference. - Unit tests: `LastfmApiClientTest` (11 tests), `PluginTest` (9 tests). ### Added (Step G.2) - `AudioScanner` — Pure-PHP audio file scanner with ID3v2 (MP3), Vorbis Comment (FLAC/OGG), and MP4 atom (M4A/AAC) tag harvesting. No external dependencies required. Never throws; returns partial results on best effort. - `MusicLibraryManager` — Orchestrates music library scanning, tag harvest, and metadata enrichment via `MetadataManager`. Implements `rescanLibrary()` for full pipeline and `upsertTrack()` for single-file processing. - `MusicLibraryType` — Library type plugin implementing `LibraryTypeInterface` with type `'music'`. Returns `AudioScanner` and `MusicLibraryManager` instances. - `LibraryTypeInterface` — New interface for library type plugins, allowing type-specific scanner and manager instances. - `MusicController` — REST API endpoints for music browsing: - `GET /music/artists` — list all artists - `GET /music/artists/{mbid}` — artist detail with albums - `GET /music/albums` — list all albums - `GET /music/albums/{mbid}` — album detail with tracks - `GET /music/tracks` — list all tracks (paginated) - `GET /music/tracks/{id}` — single track - `GET /music/now-playing` — current playback state - `Router::music()` — Registers `/music/*` routes pointing to `MusicController`. - `WebPortalRouter` — Added `/music`, `/music/artists`, `/music/albums`, `/music/tracks`, `/music/player` web portal routes. - Smarty templates — `music/artists.tpl`, `music/artist.tpl`, `music/albums.tpl`, `music/album.tpl`, `music/tracks.tpl`, `music/player.tpl`, `music/partials/music_card.tpl`. - `public/assets/css/music.css` — Styles for artist grid, album grid, track list, and player bar. - `public/assets/js/music-player.js` — Music player JavaScript with play, pause, seek, next/prev, shuffle, repeat, and queue management. - `migrations/011_music_library.sql` — Adds 'track' to media_items type enum, adds indexes for library_type, artist, album, and genre queries. - `docs/libraries/music.md` — Developer documentation covering supported formats, tag field mapping, naming conventions, scan behavior, and API. - Unit tests: `AudioScannerTest` (8 tests), `MusicLibraryManagerTest` (8 tests), `MusicControllerTest` (13 tests). ### Added (Step G.1) - `MusicBrainzProvider` — MusicBrainz API v2 metadata provider implementing `MetadataProviderInterface`. Supports artist, album, and track search and detail retrieval with MusicBrainz-required User-Agent headers and 1 req/sec rate limiting via `MusicMetadataProviderTrait`. - `AudioDbProvider` — AudioDB API v1 metadata provider implementing `MetadataProviderInterface`. Supports artist, album, and track search and detail retrieval. Degrades gracefully when no API key is configured. - `MusicMetadataProviderTrait` — shared trait for music providers with `rateLimit()` for enforcing request delays and `mbHeaders()` for MusicBrainz-required headers. - `MetadataProviderInterface` — added `MEDIA_TYPE_ALBUM`, `MEDIA_TYPE_ARTIST`, `MEDIA_TYPE_TRACK` constants and `getSourceName()` method. - `MetadataHttpClient` — extended `get()` method to accept optional `$headers` parameter for custom request headers. - `MetadataManager` — updated provider priority to include `audiodb` as fallback for music types; added `track` media type support. - `config/music_providers.php` — new config file with MusicBrainz and AudioDB provider settings (rate limits, user-agent, API key, fallback behavior). - `docs/developers/music-providers.md` — developer documentation covering provider architecture, configuration keys, MusicBrainz rate-limit requirements, and guide for adding third-party providers. - Unit tests: `MusicBrainzProviderTest` (10 tests), `AudioDbProviderTest` (11 tests) with ≥85% coverage on both providers. ### Added (Step F.5) - `ComskipRunner` — detects and runs the comskip binary on Live TV recordings; `isAvailable()` checks if the binary exists and is executable, `run()` executes comskip with a 5-minute timeout and returns the path to the generated .edl file. - `ComskipEdlParser` — parses comskip EDL (Edit Decision List) files with 3-column tab-separated format (start_seconds, end_seconds, scene_type); filters segments shorter than `min_commercial_length`; converts to `ChapterMarker[]` DTOs. - `ComskipPostProcessor` — orchestrator that runs comskip after a recording completes, parses the EDL, and stores chapters via `MarkerService::storeChapters()`. Idempotent — skips recordings that already have chapters. - `RecordingHooks::register()` — wires `ComskipPostProcessor` into the `Recorder` via the new `onComplete()` callback hook. - `Recorder::onComplete()` — registers callbacks to fire after a recording stops with status COMPLETED; callbacks receive `(string $mediaItemId, string $recordingPath)`. - `MarkerService::storeChapters()` — persists `ChapterMarker[]` arrays to `chapters_json` column via `ItemRepository::updateMarkers()`. - `config/comskip.php` — comskip binary path, `min_commercial_length` (30s), `require_confidence` (0.7), `post_process_immediately` flag, and `edl_output_dir`. - `docs/advanced/live-tv-comskip.md` — user-facing documentation covering comskip installation, configuration, EDL format, and troubleshooting. - Unit tests: `ComskipRunnerTest` (6 tests), `ComskipEdlParserTest` (12 tests), `ComskipPostProcessorTest` (6 tests). ### Added (Step F.4) - `SkipButtonSpec` — immutable value object with `toArray()` serialization and `fromMarkerSet()` factory for client-facing JSON. - `PlaybackMarkerService` — provides `getFullSpec()` and `getSkipSpec(id, position_ticks)` to return position-aware skip button specs. - `WebPortalRouter::getPlaybackInfo()` — embeds `markers` key with `skip_intro_start`, `skip_intro_end`, `skip_outro_start`, `skip_outro_end` in the playback info response. - `docs/reference/skip-button-protocol.md` — full protocol specification for client teams implementing skip button UI. - `docs/clients/skip-button-integration-brief.md` — concise hand-off brief for Phase M client integration. - `docs/reference/api.md` — updated with `GET /api/v1/media/{id}/playback` endpoint documentation including `markers` key. - Unit tests: `SkipButtonSpecTest` (4 tests), `PlaybackMarkerServiceTest` (4 tests). ### Added (Step F.3) - Marker storage columns and GET API for chapters, intro, and outro markers. - `migrations/003_marker_columns.sql` — adds `intro_start_seconds`, `intro_end_seconds`, `outro_start_seconds`, `outro_end_seconds`, `chapters_json` columns to `media_items` table. - `IntroMarker` / `OutroMarker` / `ChapterMarker` — immutable DTOs for marker segments with start/end times, confidence, and optional title. - `MarkerSet` — aggregate DTO containing intro, outro, and chapters array with `hasMarkers()` and `toArray()` methods. - `MarkerService` — service for reading/promoting markers; reads formal columns first, falls back to `metadata_json` candidates; exposes `getMarkers()`, `promoteCandidates()`, `promoteShowMarkers()`, and `getShowMarkers()`. - `MarkerController` — HTTP controller with 4 GET endpoints: - `GET /api/v1/media/{id}/markers` — all markers for an item - `GET /api/v1/media/{id}/markers/intro` — intro marker only - `GET /api/v1/media/{id}/markers/outro` — outro marker only - `GET /api/v1/shows/{id}/markers/bulk` — all episode markers for a show - `Router::markers()` — registers the 4 marker routes. - `ItemRepository` — added `getIntroMarker()`, `getOutroMarker()`, `getChapters()`, and `updateMarkers()` methods for marker column access. - `docs/reference/api.md` — API reference documentation for marker endpoints. - Unit tests: `MarkerSetTest` (10 tests), `MarkerServiceTest` (9 tests), `MarkerControllerTest` (10 tests). ### Added (Step F.2) - Intro/outro detection background job system using audio fingerprint clustering. - `FingerprintClusterer` — Jaccard similarity-based clustering to detect shared intro/outro segments across episodes using audio fingerprints. - `IntroDetectionJob` — orchestrates detection for all episodes of a TV show, clusters fingerprints, returns marker candidates. - `IntroMarkerCandidate` / `OutroMarkerCandidate` — immutable DTOs for detected intro/outro segments with start/end times, fingerprint, and confidence score. - `IntroDetectionResult` — result container for show-level detection results. - `ClusteringResult` — result container for fingerprint clustering output. - `StoredMarkers` — parses stored marker candidates from episode metadata. - `MarkerCandidateRepository` — persists intro/outro candidates to `media_items.metadata_json` for consumption by F.3 API. - `MarkerCandidateStore` — file-based job queue (`/tmp/phlix_marker_jobs/`) with one lock file per show being processed. - `BackgroundDetectorWorker` — queue consumer loop that processes detection jobs continuously. - `scripts/run-marker-detection-worker.php` — CLI entry point for running the background worker. - `config/marker_detection.php` — configuration for intro/max duration, similarity threshold (0.85), minimum episodes (3), worker interval. - `docs/developers/intro-outro-detection.md` — developer documentation covering the clustering algorithm, configuration, and usage. - Unit tests: `IntroDetectionJobTest` (5 tests), `FingerprintClustererTest` (12 tests), `MarkerCandidateStoreTest` (10 tests), `MarkerCandidateRepositoryTest` (5 tests). ### Added (Step E.6) - Subtitle burn-in (hardsubbing) pipeline for embedding subtitles directly in the video stream — required for players/devices that don't support external subtitle tracks (many smart TVs, game consoles, some mobile browsers). - `SubtitleFormat` — enum with SRT, ASS, SSA, VTT, HDMV formats plus `getFfmpegFormat()` and `supportsFontstyle()` methods. - `SubtitleTrack` — immutable value object with stream index, language code, display label, format, and file path. - `SubtitleStyleOptions` — value object for burn-in styling (font, size, primary/outline colors, outline thickness, position, margin) with `toAssStyle()` and `toSrtStyle()` methods. - `SubtitleBurner` — core class for subtitle stream detection, extraction, and FFmpeg filter graph generation for burn-in across all vendors. - `SubtitleBurnerFactory` — factory for creating vendor-specific burners. - `HwaccelCommandBuilder` — added `setSubtitleTrack()`, `setSubtitleStyle()`, and `setSubtitleBurner()` methods; integrates subtitle burn-in filter args into hardware transcoding commands. - `StreamManager` — added `setSubtitleBurnIn()` and `getSubtitleBurnInConfig()` methods for configuring subtitle burn-in per streaming session. - `StreamState` — added `subtitleBurnInIndex` and `forceSubtitleBurnIn` properties. - `config/subtitles.php` — subtitle configuration with `enabled`, `default_language`, `burn_in_by_default`, `extract_to_dir`, and `style` options. - `config/ffmpeg.php` — added `subtitles` key referencing `config/subtitles.php`. - `docs/developers/subtitle-processing.md` — developer documentation covering soft vs. hard subtitling, vendor burn-in support matrix, styling reference, and usage examples. - Unit tests: `SubtitleFormatTest` (11 tests), `SubtitleTrackTest` (4 tests), `SubtitleStyleOptionsTest` (6 tests), `SubtitleBurnerTest` (13 tests). ### Added (Step E.5) - Trickplay (thumbnail seek / scrub preview) support for video progress bar hover preview using DASH-IF / HLS spec-compliant "BIF" (Bitmap Image Format) thumbnail grids. - `TrickplayConfig` — value object with grid dimensions (8×4), thumbnail size (160×90px), interval (10s), image format (JPEG/PNG), and quality settings. - `TrickplayResult` — result container with job ID, interval, grid dimensions, image file metadata (byte offsets for byte-range requests), and BIF index XML path. - `TrickplayGenerator` — extracts frames at fixed intervals using FFmpeg batch extraction (`generateThumbnailBatch`), assembles frames into grid images via FFmpeg `tile` filter, generates BIF index XML with offset/length per thumbnail. - `TrickplayController` — HTTP handler serving thumbnail grid images and BIF index XML with correct `Content-Type` headers. - `StreamManager` — added `setTrickplay()` and `generateTrickplay()` methods, `TrickplayGenerator` and `TrickplayController` properties, and `getTrickplayController()` getter. - `FfmpegRunner` — extended `generateThumbnail()` to accept `int|array` for batch extraction, added `generateThumbnailBatch()` for multiple timestamps in one command, added `getFfmpegPath()` accessor. - `Router` — added `trickplay()` route registration for `GET /trickplay/{jobId}/thumb-{index}.jpg` and `GET /trickplay/{jobId}/index.xml`. - `config/trickplay.php` — trickplay configuration with `enabled`, `interval_seconds`, `grid_columns`, `grid_rows`, `thumb_width`, `thumb_height`, `image_format`, `jpeg_quality`, `storage_dir`. - `docs/developers/streaming-protocols.md` — added "Trickplay / Thumbnail Seek" section documenting BIF format, generation pipeline, configuration, and client-side usage. - Unit tests: `TrickplayConfigTest` (15 tests), `TrickplayResultTest` (9 tests), `TrickplayGeneratorTest` (8 tests), `TrickplayControllerTest` (10 tests). ### Added (Step E.4) - DASH (Dynamic Adaptive Streaming over HTTP) streaming support alongside existing HLS implementation. - `DashStreamer` — DASH manifest generator and segment manager producing DASH-IF compliant MPD manifests with SegmentTemplate elements. - `SegmentTemplate` — value object for DASH segment template handling (SegmentTemplate vs. SegmentList for efficient live streaming). - `AdaptationSet` — value object representing DASH adaptation sets (video, audio, text) with codec/bandwidth metadata. - `DashController` — HTTP endpoints for DASH streaming: `GET /dash/{jobId}/manifest.mpd`, `GET /dash/{jobId}/{setId}/manifest.mpd`, `GET /dash/{jobId}/{setId}/segment_{n}.m4s`. - `config/dash.php` — DASH-specific configuration with `enabled`, `manifest_refresh_seconds`, `min_buffer_time`, `min_buffer_time_live`, `time_shift_buffer_depth`, `default_codecs`. - `config/ffmpeg.php` — added `dash` key with `enabled`, `segment_dir`, `default_codecs`. - `HlsStreamer` — added `setSegmentContent()` method so segment writer can store once and both HLS and DASH streamers reference the same files. - `StreamManager` — added `DashStreamer` property and `getManifestUrl()` method returning HLS or DASH manifest URL based on `$protocol` parameter. - `Router` — added `dashStreaming()` route registration method. - `docs/developers/streaming-protocols.md` — documentation covering HLS vs. DASH tradeoffs, manifest structure, client-side selection, and usage. - Unit tests: `DashStreamerTest` (11 tests), `SegmentTemplateTest` (7 tests), `AdaptationSetTest` (8 tests). ### Added (Step E.1) - Hardware acceleration probe system for detecting GPU encoders (NVENC, VAAPI, QSV, VideoToolbox, AMF, V4L2) at startup. - `HwaccelCapability` — immutable value object representing hardware encoder capabilities (vendor, encoder/decoder names, supported codecs, HDR tone mapping support, resolution/bitrate limits). - `HwaccelProbe` — runs vendor-specific probes via `ffmpeg -encoders` and `ffmpeg -decoders`, aggregates results into a capability map. - `HwaccelRegistry` — lazy singleton holding probed capabilities; `getEncoder()` / `getDecoder()` use vendor priority for best-match selection. - `VendorProbeInterface` + 7 concrete implementations: `NvencProbe`, `VaapiProbe`, `QsvProbe`, `VideoToolboxProbe`, `AmfProbe`, `V4L2Probe`, `SoftwareProbe` (always-available fallback). - `config/hwaccel.php` — `enabled`, `prefer_hardware`, `vendor_priority`, `probe_timeout`, `test_clip_path`, `fallback_to_software` configuration. - `config/ffmpeg.php` — added `hwaccel` key with `enabled`, `prefer_hardware`, `vendor_priority`. - `FfmpegRunner` — added `HwaccelRegistry` property and `probeHardwareAcceleration()` + `buildHwaccelCommand()` methods. - `docs/developers/hardware-acceleration.md` — architecture overview, capability fields, usage examples, and guide for adding new vendors. - Unit tests: `HwaccelCapabilityTest` (6 tests), `HwaccelProbeTest` (9 tests), `HwaccelRegistryTest` (8 tests). - No user-visible behavior change yet — transcode remains software-only until Step E.2 integrates hardware encoding into TranscodeManager. ### Added (Step D.5) - Hub-side invite-link sharing (D.5). Invite links are generated on the hub and grant library access to recipients. Server-side is unchanged; library shares are synced via the existing hub heartbeat mechanism. ### Added (Step D.4) - First-class passkey / WebAuthn support for passwordless login. Supports platform authenticators (Touch ID, Windows Hello, Face ID) and roaming FIDO2 tokens (YubiKey, etc.). - `src/Auth/WebAuthn/WebAuthnManager` — orchestrates registration and authentication ceremonies; generates cryptographically random challenges; validates attestation and assertions. - `src/Auth/WebAuthn/WebAuthnCredential` — entity for stored credentials with VARBINARY credential ID, sign counter, and device metadata. - `src/Auth/WebAuthn/WebAuthnSettings` — RP configuration (ID, name, origin, attestation requirement). - `src/Auth/WebAuthn/WebAuthnCredentialRepository` — data access for `webauthn_credentials` table; implements replay attack detection via sign counter validation. - `src/Auth/WebAuthnProvider` — implements `ProviderInterface` for WebAuthn as an auth provider alongside OIDC/LDAP. - `src/Server/Http/Controllers/WebAuthnController` — HTTP API with 6 endpoints for registration, authentication, and credential management. - Database migration `migrations/010_webauthn_credentials.sql` — creates `webauthn_credentials` table with VARBINARY credential_id and foreign key to users. - Smarty template `public/templates/auth/webauthn-settings.tpl` — user-facing passkey management UI. - Routes wired in `Application::loadApiRoutes()`: `POST /api/v1/auth/webauthn/register/options`, `POST /api/v1/auth/webauthn/register/verify`, `POST /api/v1/auth/webauthn/login/options`, `POST /api/v1/auth/webauthn/login/verify`, `GET /api/v1/me/webauthn/credentials`, `DELETE /api/v1/me/webauthn/credentials/{id}`. - Composer dependency added: `web-auth/webauthn-lib: ^4.0`. - Unit tests in `tests/Unit/Auth/WebAuthn/`: `WebAuthnManagerTest`, `WebAuthnCredentialTest`, `WebAuthnControllerTest`, `WebAuthnProviderTest`. - Documentation: - `docs/plugins/auth-providers.md` — passkeys section added. - `docs/reference/api/auth-webauthn.md` — new API endpoint reference. - `docs/security/passkeys.md` — user-facing passkey guide. ### Added (Step D.3) - `phlix-plugin-ldap` — LDAP authentication provider plugin. Supports OpenLDAP and Active Directory via the LDAP protocol. Includes: - `LdapProvider` — implements `ProviderInterface` with bind authentication and user attribute mapping. - `LdapConnection` — wraps `directorytree/ldaprecord` Connection with request-scoped caching per host:port:ssl triple. - `UserMapper` — maps LDAP attributes to Phlix user fields (uid/sAMAccountName → username, mail → email, displayname/cn → display name, jpegPhoto/thumbnailPhoto → avatar_url). - `LdapUserInfo` — LDAP-specific user info carrier. - `LdapAdminController` — admin API for LDAP settings management and test-connection action. - Smarty settings form at `templates/ldap-settings.tpl`. - Routes wired in `AdminRoutes`: `GET /api/v1/admin/auth-providers/ldap/config`, `POST /api/v1/admin/auth-providers/ldap/config`, `POST /api/v1/admin/auth-providers/ldap/test`, `GET /api/v1/admin/auth-providers/ldap/schema`. - Composer dependency added: `directorytree/ldaprecord: ^3.0`. ### Added (Step D.2) - `phlix-plugin-oidc` — OIDC/OAuth2 authentication provider plugin. Supports any OIDC-compliant identity provider (Authelia, Authentik, Keycloak, Google, GitHub). Includes: - `OidcProvider` — implements `ProviderInterface` with authorization code flow and direct API token authentication. - `DiscoveryDocument` — cached OIDC discovery document (24 h TTL). - `IdTokenValidator` — RS256/RS384/RS512 token validation with cached JWKS. - `OidcCallbackController` — handles `/auth/oidc/authorize` and `/auth/oidc/callback` routes. - `OidcAdminController` — admin API for OIDC settings management. - Smarty settings form at `templates/oidc-settings.tpl`. - Routes wired in `Router::oidcAuth()`: `GET /auth/oidc/authorize`, `GET /auth/oidc/callback`. - Admin routes in `AdminRoutes`: `GET /api/v1/admin/auth-providers/oidc/config`, `POST /api/v1/admin/auth-providers/oidc/config`, `GET /api/v1/admin/auth-providers/oidc/schema`. - Composer dependencies added: `web-token/jwt-framework: ^3.0`, `phpseclib/phpseclib: ^3.0`. ### Added (Step D.1) - `Phlix\Auth\AuthProviderRegistry` — singleton registry holding registered {@see \Phlix\Auth\ProviderInterface} instances; resolves provider-prefixed usernames to the correct external provider. - `Phlix\Auth\ProviderManager` — bridges {@see AuthManager} to the registry; handles `provider:username` parsing and delegates to either an external provider or the standard password-based flow. - `Phlix\Auth\AuthProviderNotFoundException` — thrown when a provider-prefix references an unregistered provider. - `Phlix\Auth\AuthManager::loginWithProvider()` — authenticates a user via an external provider (OIDC, LDAP, SAML, passkey). On first login, automatically creates a local user row with `password_hash = NULL`. - `Phlix\Auth\UserRepository::findByExternalId()`, `findOrCreateByExternalId()`, `updateProviderData()` — data access for provider-linked accounts. - `Phlix\Server\Http\Controllers\AuthProviderController` — admin API for listing / enabling / disabling providers and retrieving their configuration JSON schema. - Routes wired in `AdminRoutes`: `GET /api/v1/admin/auth-providers`, `POST /api/v1/admin/auth-providers/{name}/enable`, `POST /api/v1/admin/auth-providers/{name}/disable`, `GET /api/v1/admin/auth-providers/{name}/config-schema`. - Migration `009_auth_provider_schema.sql` adds `provider` (VARCHAR 64), `external_id` (VARCHAR 255), `provider_data` (JSON) columns to `users` table, with indexes `idx_provider` and `idx_external`. - `detain/phlix-shared:^0.3.0` — new package version with `Phlix\Shared\Auth\ProviderInterface`, `AuthResult`, `UserInfo`. - `docs/plugins/developer-guide.md` — added "Auth Provider Plugins" section (Section 13) covering the interface contract, result types, manifest, lifecycle hooks, and admin API. - Unit tests: `AuthResultTest` (5 tests), `UserInfoTest` (6 tests), `AuthProviderRegistryTest` (5 tests), `ProviderManagerTest` (8 tests), `UserRepositoryExternalIdTest` (5 tests), `AuthProviderControllerTest` (6 tests). ### Added (Step C.9) - `Phlix\Hub\HubClient::sendHeartbeat()` — now includes `library_count`, `total_size_bytes`, and `library-sharing` capability in heartbeat payload to advertise library information to the hub. ### Added (Step C.8) - `Phlix\Hub\SubdomainResult` — DTO for subdomain allocation result with subdomain, fqdn, tlsCertPath, and tlsKeyPath fields. - `Phlix\Hub\SubdomainClient` — client for claiming/releasing subdomains from the hub and storing TLS configuration locally. - `Phlix\Hub\HttpClientInterface::delete()` — added DELETE method for subdomain release. - `Phlix\Hub\HttpClient::delete()` — implements DELETE method. - `Phlix\Hub\HubClient::getHttpClient()` — exposes HTTP client for use by SubdomainClient. - `scripts/claim-subdomain.php` — CLI script for claiming a subdomain. - `config/hub.php` — added `subdomain_auto_claim`, `tls_enabled`, `domain` configuration options. - `docs/dev/tls-certificates.md` — guide covering TLS setup, certificate sources (hub-provisioned vs self-signed), and security considerations. - `docs/reference/env-vars.md` — added `PHLIX_SUBDOMAIN_AUTO_CLAIM`, `PHLIX_TLS_ENABLED`, `PHLIX_DOMAIN` environment variables. ### Added (Step C.7) - `Phlix\Network\UpnpIgdClient` — UPnP-IGD client using raw sockets. SSDP M-SEARCH discovery on `239.255.255.250:1900`, SOAP `AddPortMapping` / `GetExternalIPAddress` / `DeletePortMapping` actions for automatic port forwarding on compatible routers. - `Phlix\Network\StunClient` — RFC 5389 STUN client for discovering the server's public IP address and testing port accessibility via TCP connect probe. - `Phlix\Network\NatPmpClient` — RFC 6886 NAT-PMP client for Apple AirPort routers and other NAT-PMP-compatible gateways. - `Phlix\Network\PortForwardService` — orchestrator that tries UPnP first, then NAT-PMP, then STUN for IP detection; falls back to manual port-forward instructions; stores result to `config/port-forward.json`. - `scripts/port-forward.php` — CLI with `status`, `enable`, `disable`, `info`, and `help` commands. - `src/Common\Container\Providers\NetworkServicesProvider` — registers `UpnpIgdClient`, `StunClient`, `NatPmpClient`, and `PortForwardService` in the PHP-DI container. - `config/port-forward.php` — `PHLIX_PORT_FORWARD_AUTO`, `PHLIX_EXTERNAL_PORT`, `PHLIX_EXTERNAL_HTTP_PORT`, `PHLIX_EXTERNAL_HTTPS_PORT`, `PHLIX_UPNP_ENABLED`, `PHLIX_STUN_SERVER`, `PHLIX_STUN_PORT` configuration. - `docs/hub/remote-access.md` — end-user guide covering UPnP, NAT-PMP, STUN, manual port forwarding setup, and troubleshooting. - `docs/hub-admin/network.md` — hub admin guide covering port forwarding configuration, firewall rules, and network requirements. - `docs/reference/env-vars.md` — documents port-forwarding and STUN environment variables. - `docs/reference/cli.md` — documents `port-forward.php` CLI commands. - Unit tests: `UpnpIgdClientTest` (5 tests), `StunClientTest` (8 tests), `NatPmpClientTest` (6 tests), `PortForwardServiceTest` (9 tests), `PortForwardScriptTest` (5 tests). ### Changed (Step C.7) - `Phlix\Hub\HubClient` now injects `PortForwardService` and calls `discoverHostnameCandidates()` to augment heartbeat hostname candidates with LAN IP, mDNS, and public IP endpoints when available. - `Phlix\Common\Container\ContainerFactory::defaultProviders()` now registers `NetworkServicesProvider`. ### Added (Step C.6) - `Phlix\Hub\RelayMessageFramer` — binary framing for HTTP-over-WebSocket tunnel. Wire format: `[1-byte type][4-byte seq][4-byte payload_len][payload]`. Types: HTTP_REQUEST (1), HTTP_RESPONSE (2), PING (3), PONG (4). All payloads are JSON. - `Phlix\Hub\RelayFrame` — immutable parsed frame DTO with accessors (`isRequest()`, `isResponse()`, `isPing()`, `isPong()`). - `Phlix\Hub\RelayConfig` — relay tunnel configuration from environment variables (`PHLIX_RELAY_ENABLED`, `PHLIX_RELAY_HUB_URL`, `PHLIX_RELAY_TUNNEL_HOSTNAME`, etc.). - `Phlix\Hub\RelayConsumer` — server-side Workerman consumer that opens a persistent WSS connection to the hub, receives framed HTTP requests, dispatches them to the local router, and sends responses back over the tunnel. Implements auto-reconnect with configurable delay and keep-alive ping/pong. - `Phlix\Hub\RelayApplication` — thin Workerman Worker entry point (`text://` protocol, timer-driven) wrapping `RelayConsumer`. - `config/relay.php` — `PHLIX_RELAY_ENABLED`, `PHLIX_RELAY_HUB_URL`, `PHLIX_RELAY_TUNNEL_HOSTNAME`, `PHLIX_RELAY_RECONNECT_DELAY`, `PHLIX_RELAY_PING_INTERVAL`, `PHLIX_RELAY_PING_TIMEOUT`. - `config/hub.php` — added `relay` capability to heartbeat payload. - `docs/dev/relay-protocol.md` — wire protocol reference for the HTTP-over-WebSocket relay tunnel. - `docs/reference/env-vars.md` — documents relay env vars. - Unit tests: `RelayMessageFramerTest` (13 tests covering frame round-trips, ping/pong, invalid/incomplete frames), `RelayConsumerTest` (11 tests covering config, routing, connection state). ### Changed (Step C.6) - `Phlix\Hub\HubClient::sendHeartbeat()` now advertises `relay` in the server capabilities list. - `Phlix\Server\Core\Application` now starts `RelayApplication` automatically when `config/hub-enrollment.json` exists and `PHLIX_RELAY_ENABLED=true`. - `Phlix\Common\Container\Providers\HubServicesProvider` now registers `RelayConfig`, `RelayMessageFramer`, `RelayConsumer`, and `RelayApplication` in the PHP-DI container. ### Added (Step C.2) - `Phlix\Hub\HubClient` — server-side orchestrator for server↔hub pairing, heartbeat loop, re-enrollment, and JWKS exposure. Implements the protocol defined in `docs/dev/pairing-protocol.md`. - `Phlix\Hub\Ed25519KeyManager` — generates, stores, loads, and rotates Ed25519 keypairs (libsodium `sodium_crypto_sign_*`). Key stored at `config/hub-server-key.pem` (mode 0600). Key ID is SHA-256 first 8 bytes of the public key (base64url). - `Phlix\Hub\HttpClient` — cURL-based HTTP client for hub API communication. Always sends `Accept-Phlix-Protocol: v1` header. - `Phlix\Hub\HubApplication` — thin Workerman Worker wrapper for the background heartbeat loop (`text://` protocol, timer-driven). - `Phlix\Server\Http\Controllers\HubJwksController` — serves `GET /.well-known/jwks.json` with the server's Ed25519 JWK(s). Cache-Control: public, max-age=3600. - `scripts/pair-with-hub.php` — CLI pairing script. Initiates claim request, displays claim code, polls until claimed, stores enrollment, starts heartbeat loop. - `config/hub.php` — hub subsystem configuration (`PHLIX_HUB_URL`, `PHLIX_HUB_HEARTBEAT_INTERVAL`, key/enrollment paths). - `Phlix\Common\Container\Providers\HubServicesProvider` — registers Ed25519KeyManager, HubClient, HubJwksController, HubApplication in the PHP-DI container. - `docs/reference/api/hub-jwks.yaml` — OpenAPI 3.0 spec for `/.well-known/jwks.json`. - `docs/reference/cli.md` — documents `php scripts/pair-with-hub.php`. - `docs/reference/env-vars.md` — documents `PHLIX_HUB_URL`, `PHLIX_HUB_ENROLLMENT_TOKEN`, `PHLIX_HUB_HEARTBEAT_INTERVAL`. ### Changed (Step C.2) - `src/Server/Core/Application` now starts the hub heartbeat background worker automatically when `config/hub-enrollment.json` exists. - `src/Common\Container\ContainerFactory` now wires `HubServicesProvider` into the default provider list. ### Added (Step C.5) - `Phlix\Hub\HubJwtValidator` — validates JWTs issued by the Phlix Hub using the hub's JWKS. Supports Ed25519 signature verification via `sodium_crypto_sign_verify_detached`, automatic JWKS caching with TTL, and key rotation (refetches JWKS once on unknown `kid`). - `Phlix\Hub\HubUserClaims` — immutable DTO for extracted hub JWT claims (`userId`, `serverId`, `subject`, `issuer`, `expiresAt`, `scope`). - `Phlix\Hub\JwksCache` — in-memory JWKS cache with TTL support. - `Phlix\Hub\HttpClientFactory` — factory for creating HTTP clients used by `HubJwtValidator` to fetch JWKS (enables testability). - `Phlix\Server\Http\Middleware\HubJwtMiddleware` — validates hub JWTs on routes that support hub-mediated access. Populates `$request->hubUser` with `HubUserClaims` on success; returns 401 on invalid/expired tokens. - `Phlix\Server\Http\Controllers\HubTokenController` — exchanges a hub JWT for a server-issued session token via `POST /api/v1/auth/hub-token`. Provides backward compatibility for older clients that present a hub JWT to get a server session token. - `Phlix\Server\Http\Request::$hubUser` — new property holding `HubUserClaims` when the request was authenticated via hub JWT. - `config/hub.php` — added `hub_jwks_url` key (`PHLIX_HUB_JWKS_URL` env var) for the hub's JWKS endpoint. - `docs/reference/env-vars.md` — documents `PHLIX_HUB_JWKS_URL`. - Unit tests: `HubJwtValidatorTest`, `HubUserClaimsTest`, `JwksCacheTest`, `HubJwtMiddlewareTest` (18 new tests). ### Changed (Step C.5) - `Phlix\Common\Container\Providers\HubServicesProvider` now registers `HubJwtValidator`, `HubTokenController`, `HubJwtMiddleware`, `HttpClientFactory`, and `JwksCache`. - `Phlix\Server\Core\Application` now registers the `POST /api/v1/auth/hub-token` route. ## [0.11.0] — 2026-05-17 ### Changed - Repository moved from `github.com/detain/phlix` to `github.com/detain/phlix-server`. The local working directory stays `/home/sites/phlix` per the expansion plan; only the `origin` remote URL changes. Update your local clone with `git remote set-url origin git@github.com:detain/phlix-server.git`. The old `detain/phlix` repo is archived (B.4b) with a README pointing at the new home. - Refactored to depend on `detain/phlix-shared:^0.2`. The `LifecycleInterface`, manifest DTOs, event DTOs, and `EventNameMap` now live in the shared package. Old FQCNs (`Phlix\Plugins\Contract\LifecycleInterface`, `Phlix\Plugins\Manifest`, `Phlix\Plugins\ManifestType`, `Phlix\Plugins\ManifestValidationError`, `Phlix\Plugins\EventNameMap`, `Phlix\Common\Events\*`) remain as deprecated aliases through 0.11.x; removed in 0.12.0. - Manifest schema validation extracted to `Phlix\Plugins\Manifest\ManifestSchema`. ### Added - Composer require on `detain/phlix-shared:^0.2.0` via a VCS repositories entry. - `src/Plugins/AliasCompatShim.php` registers the 16 `class_alias` entries for the moved classes. - Three-line interface bridge at `src/Plugins/Contract/LifecycleInterface.php` (extends the shared interface — `class_alias` doesn't work for interfaces). - Complete plugin developer documentation ([`docs/plugins/developer-guide.md`](docs/plugins/developer-guide.md)) covering plugin types, manifest, lifecycle, event subscription, settings, signing, packaging, local testing, and publishing — plus a matching server-internals reference for contributors extending the loader ([`docs/dev/plugin-sdk.md`](docs/dev/plugin-sdk.md)). Phase A is now functionally complete; the plugin system is ready for external authors. `docs/plugins/install-from-catalog.md` rewritten to set expectations about the catalog's Phase L delivery; README promotes the developer guide and the reference plugin. - Plugin manifest specification (`docs/plugins/manifest.md`, `docs/plugins/manifest.schema.json`) and the `Phlix\Plugins\Manifest` value object that parses and validates `plugin.json` files. The eleven plugin types from `PHLIX_EXPANSION_PLAN.md` §5 are codified as the `Phlix\Plugins\ManifestType` enum. No loader yet — see Step A.4. Adds `justinrainbow/json-schema:^5.2` as a runtime dependency. - PSR-11 dependency injection container (PHP-DI). Application services are now auto-wired; the legacy ConnectionPool / LoggerFactory statics remain for backwards compatibility but are wrapped behind container bindings. - `phpstan/phpstan` (level 9) and `squizlabs/php_codesniffer` (PSR-12) added as require-dev so the documented "minimum bar" is actually enforceable. A `phpstan-baseline.neon` absorbs pre-existing errors so new code is held to the bar without forcing a repo-wide refactor. - `docs/dev/architecture-server.md` and `docs/reference/env-vars.md`. - PSR-14 event dispatcher (Crell\Tukio). Playback, library-scan, and auth lifecycle events are now published from `PlaybackController`, `MediaScanner`, and `AuthManager`; plugins will be able to subscribe in Phase A.4. Twelve typed `readonly` event DTOs ship in `src/Common/Events/`. New env var `PHLIX_DEBUG_EVENTS` and `events` log channel. Canonical catalog in `docs/dev/event-reference.md`. - Plugin loader (`Phlix\Plugins\PluginLoader`) with the full install / enable / disable / uninstall lifecycle. Plugins can be installed from a URL (HTTPS + `file://` by default; HTTP behind `PHLIX_PLUGINS_ALLOW_HTTP=1`) or from a local directory; each plugin gets its own Composer-resolved `vendor/` tree under `var/plugins/<name>/`. The lifecycle contract lives in `Phlix\Plugins\Contract\LifecycleInterface` (temporary home — moves to `Phlix\Shared\Plugin` in B.1). New table `plugins` (migration `migrations/003_plugins.sql`). New `plugins` log channel and config key. New env vars: `PHLIX_PLUGINS_ALLOW_HTTP`, `PHLIX_PLUGINS_REQUIRE_SIGNATURE`, `PHLIX_PLUGINS_COMPOSER_TIMEOUT`. Adds `symfony/process:^7.0`. See `docs/plugins/developer-guide.md` for the lifecycle diagram and a sample `LifecycleInterface` implementation. - Plugin admin UI at `/admin/plugins` and JSON API under `/api/v1/admin/plugins/*` (list / install / enable / disable / uninstall). All routes gated by a new `AdminMiddleware` that reads the new `users.is_admin` flag (migration `004_admin_user_flag.sql`). The first user registered after the migration is auto-promoted to admin; subsequent users default to `is_admin = 0`. Adds runtime Composer dep `smarty/smarty:^4.0` (already used at runtime; now declared). OpenAPI spec at `docs/reference/api/admin-plugins.yaml`; end-user docs at `docs/plugins/install-from-url.md`. Editable settings UI deferred to a later phase — A.5 renders settings read-only with `secret: true` fields masked. - Reference plugin [`phlix-plugin-example`](https://github.com/detain/phlix-plugin-example) — the first community-shaped Phlix plugin, published as its own public GitHub repo. Implements `Phlix\Plugins\Contract\LifecycleInterface` as a `metadata-provider` that returns `['title' => 'Hello, World']` for a fixed fixture path, and ships unsigned by design as the canonical fork-as-starter template for plugin authors. Installable through the A.5 admin UI by pasting `https://raw.githubusercontent.com/detain/phlix-plugin-example/main/plugin.json` into **Install from URL**. Server-side wiring: new fixture `tests/fixtures/plugins/example-manifest.json` mirrors the published manifest so the loader's URL-install test can use a `file://` URL, and `docs/plugins/install-from-url.md` / `docs/plugins/trusted-plugin-list.md` now reference the live example URL. ### Deprecated - `Phlix\Server\Core\Application::getInstance()` — resolve services from the PSR-11 container instead. Slated for removal in Phase B.