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

@@ -72,6 +72,15 @@ final class ModuleManifest
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
public readonly array $authorizationPolicies;
/** @var list<string> Module IDs required by this module */
public readonly array $requires;
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
public readonly array $eventListeners;
/** @var class-string|null ModuleDeactivationHandler FQCN */
public readonly ?string $deactivationHandler;
public readonly ?string $migrationsPath;
public readonly string $basePath;
@@ -107,6 +116,14 @@ final class ModuleManifest
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
$deactivationHandler = $raw['deactivation_handler'] ?? null;
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
? trim($deactivationHandler)
: null;
$migrationsPath = $raw['migrations_path'] ?? null;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
@@ -332,6 +349,83 @@ final class ModuleManifest
return $types;
}
/**
* @return array<string, list<array{class: class-string, method: string}>>
*/
private static function normalizeEventListeners(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
);
}
$listeners = [];
foreach ($value as $eventName => $handlers) {
$eventName = trim((string) $eventName);
if ($eventName === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
);
}
if (!is_array($handlers)) {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
);
}
$normalizedHandlers = [];
foreach (array_values($handlers) as $index => $handler) {
if (!is_array($handler)) {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
);
}
$class = trim((string) ($handler['class'] ?? ''));
$method = trim((string) ($handler['method'] ?? ''));
if ($class === '' || $method === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
);
}
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
}
if ($normalizedHandlers !== []) {
$listeners[$eventName] = $normalizedHandlers;
}
}
return $listeners;
}
/**
* @return list<string>
*/
private static function normalizeRequires(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
);
}
$requires = [];
foreach (array_values($value) as $index => $item) {
if (!is_string($item) || trim($item) === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
);
}
$requires[] = trim($item);
}
return array_values(array_unique($requires));
}
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
{
$raw = trim((string) $value);