Files
breadcrumb-the-shire/agent-system/runs/MODULAR-MONOLITH-V1-001/plan.json
fs c364e2b46d feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.

Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider

Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.

Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:43:25 +01:00

330 lines
21 KiB
JSON

{
"task_id": "MODULAR-MONOLITH-V1-001",
"summary": "Introduce a module kernel (ModuleManifest, ModuleRegistry) and extract the address book as the first modular pilot. The address book is currently hardcoded across 19+ files (icon bar, aside panels, layout context, global search, permissions, admin nav, session, assets). This task builds a stable module foundation in Core, then reconnects the address book through contracts (routes, UI slots, search provider, layout context provider, session provider, permissions). V1 stays single-deployable with shared DB — only integration points are modularized.",
"assumptions": [
"V1 remains a single deployable (no distributed microservices).",
"V1 module uses shared DB; hard data separation per module comes in a later phase.",
"Module activation is via config/ENV (config/modules.php + APP_ENABLED_MODULES CSV), not via UI in V1.",
"Module load order is deterministic: load_order then id.",
"Conflict strategy is strict (fail-fast, not silent override).",
"Graceful degradation on deactivation: only UI/routing/search/permissions are hidden; DB data (tables, permission assignments) stays intact. No destructive actions on deactivation.",
"No auto-migrate or auto-build at runtime. Migrations and PageRoot build run exclusively via CLI in the deploy/entrypoint flow.",
"appBuildLayoutNavContext() in lib/Support/helpers/app.php (lines 593-638) is the central layout data builder that must be extended with provider hooks.",
"APP_ROUTE_DEFINITIONS and APP_PUBLIC_PATHS are PHP constants defined at config-load time (before container boot) — module routes must be merged after module loading.",
"SearchSqlResourceProvider, SearchUiMetaProvider, and SearchItemMapperProvider each contain hardcoded switch/case branches for address-book that must be extracted.",
"PermissionService::ADDRESS_BOOK_VIEW, UiCapabilityMap::LAYOUT['can_view_address_book'], and OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW are Core constants that must move to the module.",
"Session keys $_SESSION['available_departments_by_tenant'] are populated at login time and consumed by the aside template — this coupling must be broken via SessionProvider contract."
],
"scope": {
"in": [
"lib/App/ — new ModuleManifest, ModuleRegistry, module-aware container boot (registerContainer.php)",
"lib/Support/helpers/app.php — appBuildLayoutNavContext() provider hook",
"lib/Service/Permission/PermissionService.php — dynamic module permissions",
"lib/Service/Access/UiCapabilityMap.php — dynamic capability keys from module registry",
"lib/Service/Access/OperationsAuthorizationPolicy.php — extract ADDRESS_BOOK_VIEW ability",
"lib/Support/Search/SearchSqlResourceProvider.php — extract address-book resource",
"lib/Support/Search/SearchUiMetaProvider.php — extract address-book case",
"lib/Support/Search/SearchItemMapperProvider.php — extract address-book case",
"lib/Service/Search/SearchDataService.php — provider collection from registry",
"web/index.php — route merge after module loading, layout context hook",
"config/config.php.example — route/path constants to lazy registry",
"config/modules.php — new module activation config",
"config/assets.php — module asset group merge",
"templates/partials/app-main-aside-icon-bar.phtml — slot rendering for tabs",
"templates/partials/app-main-aside.phtml — slot rendering for panels and admin nav",
"bin/module-migrate.php — new CLI for module migrations",
"bin/module-build.php — new CLI for runtime PageRoot build",
"docker/php/docker-entrypoint.sh — module-migrate and module-build calls",
"modules/addressbook/ — new module directory with manifest, providers, migrations",
"db/updates/ — module_migrations tracking table",
"tests/ — contract tests for module loader, slot rendering, conflict cases, on/off modes",
"tests/Architecture/ — grep-based test: no address-book hardcodings in Core"
],
"out": [
"Address book business logic refactoring (AddressBookService stays shared-db based in V1)",
"UI/UX redesign of address book views (pages/address-book/*.phtml stay unchanged)",
"Frontend JS/CSS changes beyond asset group registration",
"Multi-module scenarios (only one module: addressbook)",
"Database migration framework (idempotent SQL scripts, no framework)",
"Admin UI for module activation (config/ENV only in V1)",
"Module-level DI container isolation (shared container in V1)",
"Performance optimization or caching for module registry"
]
},
"guardrails": {
"required_guard_ids": [
"GR-CORE-001",
"GR-CORE-002",
"GR-CORE-005",
"GR-CORE-006",
"GR-CORE-008",
"GR-CORE-010",
"GR-CORE-011",
"GR-CORE-012",
"GR-SEC-001",
"GR-SEC-003",
"GR-LANG-001",
"GR-LANG-002",
"GR-TEST-001",
"GR-TEST-002",
"GR-UI-007",
"GR-UI-008",
"GR-UI-009",
"GR-UI-010",
"GR-UI-015",
"GR-UI-016"
],
"required_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-004",
"QG-005",
"QG-006"
]
},
"success_criteria": [
{
"id": "SC-001",
"criterion": "Application starts without modules enabled (APP_ENABLED_MODULES='') with zero errors — identical behavior to current state."
},
{
"id": "SC-002",
"criterion": "Application starts with APP_ENABLED_MODULES='addressbook' and loads manifest, registrars, routes, UI slots, search provider, layout context provider, session provider, and permissions correctly."
},
{
"id": "SC-003",
"criterion": "Duplicate route/slot-key/search-key/permission-key causes fail-fast with clear error message identifying the conflict source."
},
{
"id": "SC-004",
"criterion": "With addressbook disabled: icon bar has no People tab, aside has no People panel, global search has no address-book section, admin nav shows no address-book-related items, permissions list excludes address_book.view."
},
{
"id": "SC-005",
"criterion": "With addressbook enabled: icon bar shows People tab, aside shows People panel with tenant/department hierarchy, global search includes address-book section with count/preview, permissions include address_book.view."
},
{
"id": "SC-006",
"criterion": "Session contains available_departments_by_tenant only when addressbook module is active. Deactivated module does not populate session keys."
},
{
"id": "SC-007",
"criterion": "bin/module-migrate.php executes addressbook migrations idempotently and tracks them in module_migrations table."
},
{
"id": "SC-008",
"criterion": "Architecture test: grep for address_book/ADDRESS_BOOK/addressBook in Core code (excluding modules/ directory) returns 0 hits."
},
{
"id": "SC-009",
"criterion": "Full PHPUnit suite passes (QG-001 exit 0) — no regressions."
},
{
"id": "SC-010",
"criterion": "PHPStan level 5 passes (QG-002 exit 0)."
},
{
"id": "SC-011",
"criterion": "PHP-CS-Fixer passes (QG-006 exit 0)."
},
{
"id": "SC-012",
"criterion": "Browser smoke test (QG-005): no new JS console errors in core flows (login, admin lists, detail pages, search, aside navigation) in both module-on and module-off modes."
}
],
"implementation_steps": [
{
"id": "S1",
"title": "Module kernel: ModuleManifest schema and ModuleRegistry",
"description": "Create lib/App/Module/ModuleManifest.php (value object with id, version, enabled_by_default, load_order, and all contribution keys: routes, public_paths, container_registrars, ui_slots, search_resources, asset_groups, scheduler_jobs, layout_context_providers, session_providers, permissions, migrations_path). Create lib/App/Module/ModuleRegistry.php that loads modules from config/modules.php + APP_ENABLED_MODULES env, validates manifests, sorts by load_order then id, merges contributions, and fail-fasts on conflicts. Create config/modules.php with empty default. Unit tests for registry loading, conflict detection, and deterministic ordering.",
"guard_refs": [
"GR-CORE-001",
"GR-LANG-001",
"GR-TEST-001",
"GR-TEST-002"
]
},
{
"id": "S2",
"title": "Module-aware container boot and permission registration",
"description": "Extend lib/App/registerContainer.php to run module container_registrars after Core registrars. Enforce rule: module registrar MUST NOT overwrite existing container keys (fail-fast). Extend PermissionService to read dynamic permissions from ModuleRegistry in addition to Core constants. Extend UiCapabilityMap to include module-sourced capabilities. Create contract interfaces: LayoutContextProvider, SessionProvider, SearchResourceProvider in lib/App/Module/Contracts/.",
"guard_refs": [
"GR-CORE-001",
"GR-CORE-002",
"GR-CORE-005",
"GR-TEST-001",
"GR-TEST-002"
]
},
{
"id": "S3",
"title": "Module migrations CLI and tracking table",
"description": "Create db/updates/ script for module_migrations table (module_id, filename, applied_at). Create bin/module-migrate.php: reads active modules from registry, scans each module's migrations_path, applies unapplied scripts in alphabetical order, records in tracking table. Idempotent (re-run safe). Add to docker/php/docker-entrypoint.sh after init.sql. Unit test for migration ordering and idempotency logic.",
"guard_refs": [
"GR-CORE-010",
"GR-SEC-003",
"GR-TEST-001"
]
},
{
"id": "S4",
"title": "Route and request guard modularization",
"description": "Replace APP_ROUTE_DEFINITIONS and APP_PUBLIC_PATHS constants in config/config.php with lazy-evaluated registry that merges Core routes + module routes after module loading. Update web/index.php to perform route merge after ModuleRegistry is initialized. Collision on same page path: fail-fast. Create bin/module-build.php for runtime PageRoot symlinks (storage/runtime/pages/) with lock file. Add to docker-entrypoint.sh. Test: module route reachable when active, 404 when inactive.",
"guard_refs": [
"GR-CORE-001",
"GR-CORE-005",
"GR-CORE-006",
"GR-SEC-001",
"GR-TEST-001"
]
},
{
"id": "S5",
"title": "UI slot layer: aside icon bar, panels, admin nav",
"description": "Refactor templates/partials/app-main-aside-icon-bar.phtml from hardcoded 5 tabs to Core tabs + slot-rendered module tabs (from ui_slots['aside.tab_panel']). Refactor templates/partials/app-main-aside.phtml: admin nav groups from static array to Core groups + module contributions (ui_slots['admin.nav_item']). People panel from inline rendering to slot-provided template. Preserve keyboard shortcuts, aside persistence, and scroll state. Test: tabs/panels appear/disappear based on module activation.",
"guard_refs": [
"GR-UI-007",
"GR-UI-008",
"GR-UI-009",
"GR-UI-010",
"GR-UI-016"
]
},
{
"id": "S6",
"title": "Layout context and session provider hooks",
"description": "Add provider loop in appBuildLayoutNavContext() (lib/Support/helpers/app.php lines 593-638): after Core data assembly, iterate ModuleRegistry::getLayoutContextProviders() and merge results into $layoutNav. Add session provider hook in login flow: after successful authentication, iterate ModuleRegistry::getSessionProviders() and call populate($user, $container). Add clear() call on logout. Test: $layoutNav contains addressBook key only when module active. Session contains available_departments_by_tenant only when module active.",
"guard_refs": [
"GR-CORE-001",
"GR-CORE-008",
"GR-TEST-001"
]
},
{
"id": "S7",
"title": "Search provider registry",
"description": "Create SearchResourceProvider interface (resources(), mapResultItem(), listUrl()). Refactor SearchSqlResourceProvider into a Core provider implementing the interface (remove address-book resource). Refactor SearchDataService to collect providers from Core + ModuleRegistry::getSearchProviders(). Remove switch/case branches for address-book from SearchUiMetaProvider and SearchItemMapperProvider — delegate to provider. Refactor asset groups in config/assets.php to support module contributions. Test: search count/preview for address-book works when module active, absent when inactive.",
"guard_refs": [
"GR-CORE-001",
"GR-SEC-003",
"GR-TEST-001",
"GR-TEST-002"
]
},
{
"id": "S8",
"title": "Address book module: manifest and providers",
"description": "Create modules/addressbook/module.php with full manifest (id: addressbook, routes, ui_slots for icon-bar tab + people panel, search_resources pointing to AddressBookSearchProvider, layout_context_providers pointing to AddressBookLayoutProvider, session_providers pointing to AddressBookSessionProvider, permissions: [address_book.view], asset_groups: [address-book], container_registrars). Create provider classes implementing the contracts. Move address-book specific permission constants from PermissionService, UiCapabilityMap, OperationsAuthorizationPolicy into module. Test: module loads, all contributions register correctly.",
"guard_refs": [
"GR-CORE-001",
"GR-CORE-002",
"GR-CORE-005",
"GR-LANG-001",
"GR-TEST-001"
]
},
{
"id": "S9",
"title": "Core cleanup: remove all address book hardcodings",
"description": "Remove address-book references from: PermissionService (ADDRESS_BOOK_VIEW constant), UiCapabilityMap (can_view_address_book in LAYOUT), OperationsAuthorizationPolicy (ABILITY_ADDRESS_BOOK_VIEW), SearchSqlResourceProvider (address-book resource entry), SearchUiMetaProvider (address-book case), SearchItemMapperProvider (address-book case), app-main-aside-icon-bar.phtml (hardcoded People tab), app-main-aside.phtml (hardcoded People panel lines 312-387), appBuildLayoutNavContext() (addressBook data assembly lines 623-631). Verify each removal is covered by the corresponding module provider from S8.",
"guard_refs": [
"GR-CORE-001",
"GR-CORE-005",
"GR-UI-007",
"GR-UI-009"
]
},
{
"id": "S10",
"title": "Architecture tests and pre-extract hardening",
"description": "Add PHPUnit architecture test: grep-based scan that no Core code (lib/, config/, templates/, pages/ excluding modules/) references address_book, ADDRESS_BOOK, or addressBook. Add contract tests: ModuleRegistry load/conflict/ordering, slot rendering, SearchProvider collection, LayoutContextProvider merging, SessionProvider lifecycle. Smoke-run in two modes: APP_ENABLED_MODULES='' and APP_ENABLED_MODULES='addressbook'. All quality gates must pass in both modes.",
"guard_refs": [
"GR-TEST-001",
"GR-TEST-002",
"GR-LANG-002"
]
},
{
"id": "S11",
"title": "Run all quality gates in both module modes",
"description": "Execute QG-001 (PHPUnit full), QG-002 (PHPStan), QG-003 (Architecture Core Contract), QG-004 (Structural rg checks), QG-005 (JS smoke in browser), QG-006 (PHP Style) — first with APP_ENABLED_MODULES='', then with APP_ENABLED_MODULES='addressbook'. All must exit 0. i18n key completeness check for any new t() keys (GR-UI-015).",
"guard_refs": [
"GR-CORE-001",
"GR-LANG-002",
"GR-UI-015"
]
}
],
"ux_notes": {
"affected_patterns": [
"app-main-aside-icon-bar (templates/partials/app-main-aside-icon-bar.phtml)",
"app-main-aside (templates/partials/app-main-aside.phtml — admin nav groups, people panel)",
"app-global-search (web/js/components/app-global-search.js — search sections)",
"app-toggle-aside-panels (web/js/components/app-toggle-aside-panels.js — panel switching)",
"app-bookmark-panel (web/js/components/app-bookmark-panel.js — aside panel actions)"
],
"ui_states_required": [],
"a11y_touchpoints": [
"Module-contributed aside icon-bar tab buttons must be keyboard-operable (Tab, Enter/Space)",
"Module-contributed aside panels must be focusable and support keyboard navigation",
"Module-contributed admin nav items must use semantic HTML (nav, ul, li, a) and be keyboard-accessible"
]
},
"tests": [
"tests/Architecture/ModuleRegistryContractTest — module loading, conflict detection, deterministic ordering",
"tests/Architecture/ModuleSlotRenderingTest — UI slot registration and rendering in both modes",
"tests/Architecture/NoAddressBookHardcodingTest — grep-based: zero Core references to address_book/ADDRESS_BOOK/addressBook",
"tests/Unit/Module/ModuleRegistryTest — unit tests for manifest validation, merge, fail-fast",
"tests/Unit/Module/SearchProviderCollectionTest — Core + module search providers merged correctly",
"tests/Unit/Module/LayoutContextProviderTest — provider hook in appBuildLayoutNavContext",
"tests/Unit/Module/SessionProviderTest — populate/clear lifecycle",
"QG-001: docker compose exec php vendor/bin/phpunit (full suite, both module modes)",
"QG-002: docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress",
"QG-003: docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php",
"QG-004: Structural rg checks (exit 0)",
"QG-005: Browser smoke test — no new JS errors in login, admin, search, aside flows",
"QG-006: docker compose exec php composer cs:check"
],
"acceptance_checks": [
"SC-001: APP_ENABLED_MODULES='' → application starts, all core pages work, no errors in logs.",
"SC-002: APP_ENABLED_MODULES='addressbook' → address book icon tab, people panel, search section, permissions all present.",
"SC-003: Duplicate route in module manifest → immediate error with message identifying both sources.",
"SC-004: APP_ENABLED_MODULES='' → no People tab in icon bar, no People panel in aside, no address-book in search, no address_book.view in permissions.",
"SC-005: APP_ENABLED_MODULES='addressbook' → People tab visible (with permission), People panel renders tenant/department hierarchy, search includes address-book section.",
"SC-006: Login with/without module → session dump shows available_departments_by_tenant only when addressbook active.",
"SC-007: bin/module-migrate.php runs twice → second run reports 0 new migrations (idempotent).",
"SC-008: rg -r 'address.book|ADDRESS_BOOK|addressBook' lib/ config/ templates/ pages/ --glob '!modules/**' returns 0 matches.",
"SC-009: QG-001 exits 0 in both module modes.",
"SC-010: QG-002 exits 0.",
"SC-011: QG-006 exits 0.",
"SC-012: Browser console clean in both modes during core flow smoke test."
],
"risks": [
{
"risk": "Route merge timing breaks auth guard: APP_ROUTE_DEFINITIONS is a constant defined before container boot. Replacing it with a lazy registry could break the early auth/public-path check in web/index.php.",
"mitigation": "Implement and test route registry in S4 with both modes. Auth guard must read from registry, not constant. Smoke test login, protected pages, and public pages in both modes before proceeding."
},
{
"risk": "Runtime PageRoot build (storage/runtime/pages/) introduces race conditions when multiple FPM workers serve requests during deploy.",
"mitigation": "bin/module-build.php uses file-based lock and runs only in entrypoint (never at runtime). Symlink strategy is atomic (create temp link, rename). Alternative: explore MintyPHP Router-level multi-PageRoot support first."
},
{
"risk": "Session bloat: deactivated module leaves stale session keys from previous login.",
"mitigation": "SessionProvider::clear() called on module deactivation event. For V1 (no runtime deactivation), users must re-login after config change — document this requirement."
},
{
"risk": "Permissions disappear when module is deactivated — users with address_book.view in their role see broken UI or errors.",
"mitigation": "Graceful degradation: DB permission assignments stay intact. UI hides capability when module is off. Re-enabling module restores full functionality without re-assigning permissions."
},
{
"risk": "MintyPHP Router does not support multiple PageRoots — file-based routing assumes single pages/ directory.",
"mitigation": "Investigate Router::$pageRoot early in S4. If single-root only: use symlink approach (bin/module-build.php). If extensible: prefer Router-level solution. Decision must be made before S5."
},
{
"risk": "Large blast radius: 19+ files in Core change, plus new module directory. A subtle regression in aside rendering or search could go unnoticed.",
"mitigation": "Incremental approach: S1-S3 pure additive (no behavior change). S4-S7 add hooks but keep hardcoded paths as fallback. S8-S9 swap hardcoded to module-provided. S10-S11 verify. Each step has its own test checkpoint."
}
]
}