CHANGELOG.md
Rama master · réplica de lectura del repositorio original.
# 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-<n>]`) 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/<pid>/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_<adapter>_<frontend>.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
<measuredAt> — 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 <name>`
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/<domain>.pem`, bind
`crt <dir>` 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 "<live>/<domain>/fullchain.pem" key-file "<live>/<domain>/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="<raw DB name>"` — 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''<percent-encoded>`
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-<base64>` — 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-<output>`) 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 `<title>`/`<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