feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks

Six targeted improvements to the modular monolith platform:

1. Fix AddressBook session key prefix to follow module.<id>.* convention
2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module
3. Add declarative JSON schema for module manifests (.agents/contracts/)
4. Add `requires` field with missing-dependency and circular-dependency detection
5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout)
6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php)

Includes 15 new architecture/unit tests covering all new functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:20:13 +01:00
parent 866d43e15a
commit ef72b34c40
31 changed files with 1041 additions and 75 deletions

View File

@@ -87,6 +87,9 @@ final class ModuleRegistry
/** @var array<string, string> scheduler job key => owning module id */
private array $schedulerJobOwners = [];
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
private array $mergedEventListeners = [];
/**
* Boot the registry from a modules directory and activation config.
*
@@ -150,6 +153,7 @@ final class ModuleRegistry
$registry->registerModule($manifest);
}
$registry->validateDependencies();
$registry->finalizeMergedContributions();
return $registry;
@@ -354,6 +358,14 @@ final class ModuleRegistry
$this->mergedAuthorizationPolicies[] = $policyClass;
}
// — Merge event listeners ——————————————————————————
foreach ($manifest->eventListeners as $eventName => $handlers) {
if (!isset($this->mergedEventListeners[$eventName])) {
$this->mergedEventListeners[$eventName] = [];
}
array_push($this->mergedEventListeners[$eventName], ...$handlers);
}
// — Merge scheduler jobs —————————————————————————
foreach ($manifest->schedulerJobs as $schedulerJob) {
$jobKey = trim((string) $schedulerJob['job_key']);
@@ -388,6 +400,52 @@ final class ModuleRegistry
}
}
private function validateDependencies(): void
{
$enabledIds = array_keys($this->modules);
// Check all required modules are enabled
foreach ($this->modules as $manifest) {
foreach ($manifest->requires as $requiredId) {
if (!isset($this->modules[$requiredId])) {
throw new RuntimeException(
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
);
}
}
}
// Check for circular dependencies (DFS cycle detection)
$visited = [];
$inStack = [];
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
if (isset($inStack[$moduleId])) {
throw new RuntimeException(
"Circular module dependency detected involving module '{$moduleId}'."
);
}
if (isset($visited[$moduleId])) {
return;
}
$inStack[$moduleId] = true;
if (isset($this->modules[$moduleId])) {
foreach ($this->modules[$moduleId]->requires as $requiredId) {
$visit($requiredId);
}
}
unset($inStack[$moduleId]);
$visited[$moduleId] = true;
};
foreach ($enabledIds as $moduleId) {
$visit($moduleId);
}
}
private function finalizeMergedContributions(): void
{
$this->mergedPublicPaths = array_values(array_unique(array_filter(
@@ -626,4 +684,10 @@ final class ModuleRegistry
{
return $this->mergedSchedulerJobs;
}
/** @return array<string, list<array{class: class-string, method: string}>> */
public function getEventListeners(): array
{
return $this->mergedEventListeners;
}
}