The Modules page, dependency warnings and job messages named modules by
`name` — the code identifier (`billing`) that keys config/modules.php, the
class namespace and dependencies. module.json now takes an optional
`title` for people (the store writes its listing name there); the page
shows it with `name` under it, and everything still keys off `name`.
Without a title the name is shown, as before.
- The global search listed every kind of record, with its details, to
every admin group: it asks only for the kinds the group's pages show.
The load graph takes the dashboard's or the servers' permission, and the
category templates' actions (create, save, delete, apply to every
stream) the page's: they ran for any signed-in group.
- get_settings gave every setting to any API token that may read, the
stream tokens' key and the Redis password among them. A token that is not
a full one gets none of the secrets a load balancer's replica is not
given either, nor the metrics token and the reminders' webhook.
- config/modules.php was read, changed and written back whole: of two
processes changing it at once the second undid the first's change. A
manager writes what it changed since it read the file onto the file as it
is now, one writer at a time.
- The bouquets were read again for each of a line's bouquets (EPG API), and
the categories for each stream of a reseller's guide.
- A NULL in a column read as a JSON list ended a table, a search or a save
(count() or foreach over null): read as an empty list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Leads of the 2026-10-10 scan, re-read and confirmed:
- transactional() over a DDL statement: the statement commits on its own,
PDO then refused the commit and the rollback, so a module migration that
had done its work threw "There is no active transaction", the callback's
own error was hidden, and the handler stayed "in a transaction" for the
rest of the process. commit() and rollback() leave the transaction first
and ask PDO only for one that is still open.
- A module updated at the second attempt stayed Failed, and unloaded.
- A module archive is told apart by its content: a stored one is named
.zip whatever it is and an upload has no extension, so a tar.gz update
could not be extracted again or fetched by a load balancer, and a plain
.tar upload failed.
- A 404 page, a redirect's body or a page answered 200 was cached under an
image's name and never fetched again: only a 2xx answer that is an image
is cached.
- A prebuffer shorter than a segment returned the whole playlist
(array_slice(..., -0)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release whose module.json keeps the hash_id but changes the name used to land
beside the old copy: both booted, and the new name started with no state or
installed_version. Placing a module now drops any other copy with the same
hash_id and moves its config/modules.php entry to the new name; a git update
renames the directory and entry it wrote into.
config/bundled_modules.php listed Watch and Plex, and every
`console.php status` (install, each service start, each update)
downloaded whichever of them was not on disk: an operator could not
decline one, and a deleted module came back at the next restart. The
list and its provisioning (getStandardSet, provisionStandardSet,
installModuleFromSource, findModuleByHashId) are gone; syncBundledModules()
installs only modules already on disk. A module reaches the disk when
the operator installs it. A recovery bundle no longer carries the file;
an older one that does still opens.
`requires_core` was only read and shown in the Modules table. A module
release that needs a newer core could be installed or updated anyway and
take the panel down, the update check offered such releases, and modules
left behind by a core rollback kept loading.
ModuleLoader::coreRequirementError() checks a manifest's constraint
(one or more `>= > <= < = == !=` conditions, a bare version meaning >=;
empty = any core; anything else unreadable and refused) against
XC_VM_VERSION, a nightly `X.Y.Z-dev.N` counting as X.Y.Z. It is applied:
- on load: the module is skipped with a log line, its dependents with it;
- on install (placeModuleFiles: zip, LB, standard set, platform) and on a
git/url update: refused before the installed copy is touched; a refused
copy the platform extracted into the modules directory is removed;
- in the update check: git offers the newest release whose module.json
(repository root, read from raw.githubusercontent.com, up to 10 tags)
this core can run, url honours requires_core in version.json;
- in the Modules table: the reason on the existing warning badge, now
labelled "Issue(s)".
The checks sit in small helpers, so discoverModules() and
updateModuleFromSource() come out simpler than before.
installModule() ended with an unconditional setState(Enabled). Store updates,
zip uploads and plain reinstalls all pass through it, so switching a module off
in the panel survived only until its next update — which is how a disabled plex
came back and then blocked `watch` from being disabled at all.
The admin's choice is now captured before the Installing transition overwrites
it and restored at the end; a first install still enables, since there is no
choice to preserve. stateAfterInstall() reads the raw override rather than
listModules() so the transient Installing / Failed states cannot be mistaken
for a deliberate "disabled".
Restoring that state uses the new writeState() instead of setState(): putting a
module back into the state it already had is a lifecycle transition, not an
admin "disable", and must not be vetoed by the dependents guard — otherwise
updating a disabled `watch` would fail at the last step because `plex` exists.
setState() keeps the guard for the deliberate case.
Three tests cover it; two of them fail against the previous behaviour.
Verified: 860 tests, make gates, CRAP gate.
Disabling a module whose own dependency was already off was impossible: the
guard counted a dependent's nominal Enabled state, while ModuleLoader prunes
unsatisfiable modules transitively. A dependent sitting on an already-broken
chain is not running, so it must not block. The guard now mirrors the loader's
prune and only counts dependents that would genuinely stop working.
Alongside that, three lifecycle holes found while reviewing ModuleManager:
- updateModule() had no failure handling and no incremental watermark. A
migration failing half-way left the module Enabled at the old version, so a
retry replayed already-applied deltas — a non-idempotent one then failed
forever. File deltas and programmatic migrations are now merged into one
ascending timeline, the recorded version advances per COMPLETED version, and
a failure marks the module Failed. ModuleMigrator gains pending() and a
public applyFile(); up() is reimplemented on top of them, unchanged.
- uninstallModule() had no try/catch, unlike installModule(). A failing
uninstall() hook left the module installed and Enabled with half-deleted
data. It now marks the module Failed. The duplicated dependents guard moves
into assertNoInstalledDependents(), shared with deleteModule().
- Platform (store) installs assumed a bare modules/{slug} directory while every
other path uses {name}_{hash5}. The backup step then found nothing and the
pulled copy landed beside the existing install, leaving two manifests for one
module. The platform flow now reuses placeModuleFiles(), which drops rival
copies and moves the source into place instead of copying it; listModules()
de-duplicates by name so a stray copy cannot show the module twice.
archivesPath now sits beside modulesPath rather than resolving under MAIN_HOME,
so an injected modules path is honoured (identical path in production).
Verified: 852 tests, make gates, CRAP gate.
- ArchitectureTest/InterfaceContractTest resolve modules via module.json instead
of the directory basename (works with {name}_{hash5}).
- ModuleLoaderTest: loads from a hash-suffixed dir; a broken module and its
dependents are skipped without aborting the whole load.
- ModuleManagerMigrationsTest: legacy bare -> hashed migration (+ stale-dup drop),
and install of a module living in a hash-suffixed directory.
Move Core/Module into XcVm\Core\Module (BaseModule, ModuleInterface,
MigratableInterface, ModuleLoader, ModuleManager, NavbarRegistry, NavbarItem,
CoreNavbarProvider) and Core/Module/Contract into XcVm\Core\Module\Contract
(the 6 provider interfaces). Completes the Core hub layer.
- Namespace 14 files (8 root + 6 Contract). Root files import the contracts via
'use XcVm\Core\Module\Contract\...'; NavbarProviderInterface imports the
root NavbarRegistry. Qualify still-global deps with leading backslash
(\ServiceContainer, \CommandRegistry, \ModuleState, \ServerEnvironment,
\EventDispatcher, \ListensTo, the Module exceptions, \ZipArchive,
\InvalidArgumentException, \RuntimeException) — Container/Events/Enum/Exception
migrate later. Migrated deps (Router, StreamPipeline, ...) keep their use.
- Rewrite the modules' 'use BaseModule/NavbarRegistry/NavbarItem;' → FQCN; add
'use XcVm\Core\Module\...;' to other referrers across src/ and tests/.
- Fix pre-existing leading-backslash refs to the FQCN.
- Tests: qualify the module fixtures' generated 'use ModuleInterface;' /
'use BaseModule;' / 'use NavbarRegistry;' and the registerRoutes type hint to
FQCN; add real top-level use imports to the fixture-builder tests; update
InterfaceContractTest registerNavbar param-type to the FQCN. Repaired
use-inserter mis-placements in the two tests that declare a namespace() method.
- Public/index.php: class_exists('ModuleLoader') → class_exists(ModuleLoader::class).
- phpstan-baseline.neon regenerated (292→292; no new/unknown-class errors).
Verified: php -l clean; PHPStan no errors; PHPUnit 295/295; the 4 real modules
load and resolve as XcVm\Core\Module\ModuleInterface; leading-backslash
re-sweep across all migrated classes is clean.