Clean Framework · DOCUMENTATION
Central per-application module activation
Central per-application module activation
Bounded candidate reference — 2026-10-20: 0.2.0-rc.1 is committed locally through 84f415b3098af5653577b2c65eea31fca21df79c, unpublished and not production-approved; stable PHP >=8.5.11 <8.6.0 is required. This is documentation, not adoption: this knowledge base retains its installed 0.1.0-rc.1 dependency and PHP 8.2.11 local profile. Dated PHP 8.2 evidence is historical; earlier dirty-source reports retain their original test provenance, not retroactive clean-commit proof. Candidate examples are illustrative: API signatures were reviewed, but candidate code was not executed by this editorial update. See current checkpoint, open criteria and adoption boundary.
CF-MOD-01 is an opt-in, startup-loaded policy for optional capabilities, not process-global plugin discovery, a live admin toggle or an authorization system. Existing applications and the separate KB are not automatically migrated.
Ownership and effective decisions
| Concern | Contract |
|---|---|
| Requested activation | App-owned config/modules.json and canonical environment bindings |
| Supported code | ModuleCatalog/ModuleDefinition: bounded explicit adapter IDs, dependencies and conflicts |
| Readiness | Explicit trusted FeatureGate metadata, keyed module.adapter, defaults OFF |
| Effective availability | ModulePolicy: requested AND dependencies AND selected-adapter readiness |
| User/object authority | Existing authentication/authorization/recent-auth/CSRF; never granted by activation |
| Execution | Explicit lazy HTTP/CLI/worker/schedule guards sharing the snapshot |
The catalog is a code allowlist, not another activation preference file. No secrets, adapter class names, factories or installed-code discovery belong in the policy data. Readiness must be safe qualified metadata, not a live provider probe/factory. Composition never evaluates readiness; OFF decisions short-circuit it. Failures become fixed value-free denial categories. StaticFeatureGate and CompositeFeatureGate remain the underlying decision primitives.
Dependency health is propagated as its validated safe enum: an enabled/degraded prerequisite leaves dependants enabled but degraded, including multiple hops or mixed prerequisites. An unavailable prerequisite still denies availability. This does not alter requested flags, permission checks or UNKNOWN disposition; provider exception messages are never propagated as diagnostic reasons.
Module IDs are lowercase dotted identifiers, 2–64 characters. Underscores and hyphens are excluded to keep environment bindings collision-free. Bounds are 64 modules,16 adapters and16 dependencies/conflicts per module. Mandatory auth/authentication/authorization/csrf/privacy/security name roots are reserved. Never move a mandatory protection behind an optional switch.
Every registered module/adapter pair must have a unique module.adapter readiness identity. Catalog creation rejects ambiguity across all adapters, including OFF modules and adapters not currently selected. For example, vendor.media + probe.local collides with vendor.media.probe + local. Noncolliding dotted identifiers and readiness keys are unchanged. A previously ambiguous catalog now fails startup with a fixed safe error; choose explicit nonambiguous application IDs before adoption. No readiness probe/factory runs during this bounded validation (at most64 modules ×16 adapters).
Skeleton and configuration
config/modules.json owns declarative activation. config/module-bootstrap.php owns trusted catalog/readiness composition, shared by HTTP and CLI and pinned to Config's selected environment. The harmless example.local module has one local adapter and defaults OFF everywhere. No real provider/native/paid module is enabled.
Example only: request the harmless capability in DEV, not other environments. The supplied skeleton itself defaults OFF in all four environments.
{
"modules": {
"example.local": {"enabled": false, "adapter": "local", "disable": "complete"}
},
"environments": {
"dev": {"example.local": {"enabled": true}},
"test": {}, "stage": {}, "prod": {}
}
}Precedence: base → selected overlay → MODULE_EXAMPLE_LOCAL_ENABLED → selected prefix such as DEV_MODULE_EXAMPLE_LOCAL_ENABLED. Other environment prefixes are ignored. EnvironmentSource prefers injected runtime values to local .env, never putenv or another application's policy. Only true/false/1/0 variable strings are accepted; file enabled fields must be actual booleans. Omitted modules are OFF.
Unknown module/schema/environment names, unsupported adapters, invalid flags/ dispositions, duplicate/cyclic/unknown graph references, enabled dependencies that are disabled, and conflicting enabled modules fail startup. An enabled module requires an explicit adapter; missing readiness still denies activation. Every overlay is schema-validated; only the selected one is applied. Credentials do not enable anything. Preference files are private local application-owned data: never use request paths/uploads. load() reads at most256KiB of JSON with depth16, requires object-shaped modules/environments/settings, and rejects unavailable/nonregular files, leaf symlinks, stream-wrapper paths, malformed UTF-8/JSON and excess bounds. Fixed errors contain no paths, contents or parser details. It never evaluates PHP, installs handlers or manipulates output buffers. There is no executable fallback. This is not a hostile-filesystem sandbox.
Migration from PHP preferences (owner-approved B-005 contract)
Convert literal config/modules.php arrays manually to config/modules.json objects and update the load() path in trusted composition. Empty maps must be {}, not []; booleans must be JSON true/false, not strings or integers. Keep comments in adjacent documentation. Do not execute old config as an automatic conversion step or place executable expressions in JSON. The loader parses JSON regardless of filename and rejects PHP contents without executing them. No real consumer is migrated automatically.
PHP controllers/views and general application configuration are unchanged. fromArray() remains available for explicit trusted-code composition, but that caller owns any PHP execution before supplying data. Preserve the same closed schema and shared snapshot instead of creating a second activation source. Keep private files access-controlled and avoid duplicate JSON object keys (native JSON decoding resolves duplicate names to the last value). See the approved contract and T4 crosswalk and the preserved pre-change red findings.
HTTP and safe diagnostics
The skeleton /optional/{name} native-PHP route is guarded before controller resolution and method negotiation; OFF returns404 without Allow for GET, HEAD, OPTIONS or mismatched methods. Mixed paths advertise/select only visible methods; hidden explicit HEAD/OPTIONS cannot mask a visible fallback. In an authorized local copy, set DEV_MODULE_EXAMPLE_LOCAL_ENABLED=true and restart before visiting /optional/Module. /, /health and CSS remain unchanged. Apache static files bypass PHP: this is not private-file authorization, asset revocation or hotlink protection.
use Clean\Framework\Module\ModuleMiddleware;
// Application-supplied authentication/CSRF remain mandatory and independent.
$router->post('/reports', $applicationHandler)->middleware(
new ModuleMiddleware($modules, 'reports.export'), $authentication, $csrf
);Pass the actual ModuleMiddleware object as shown: it implements the typed RouteVisibility contract, so the router consults the exact dispatch-policy snapshot after path/constraint matching and before methods or Allow. Group-inherited objects participate; all visibility checks must admit. No migration is needed for this published fluent idiom. Opaque service IDs/closures wrapping a guard are deliberately not resolved during discovery: adopt the direct object rather than assume automatic concealment. Existing global/auth/authorization/CSRF middleware still enforces actual execution. Canonical redirects remain route-independent: visible, hidden and unknown safe aliases receive the same308, then the canonical target enforces visibility. NORMALIZE/STRICT and unsafe-alias behavior are unchanged. Public static files, trusted CLI route diagnostics and named URL generation are not concealment APIs.
APP_ENV=DEV php bin/console modules:statusRead-only status exposes bounded IDs, environment, requested/effective booleans, adapter ID, reason/health and disable disposition. Never config values, secrets, readiness exception messages or private paths. It is not a public route or activation writer; OS/operator access still protects CLI execution.
Lazy CLI, queue and scheduler entry points
The following fragments require registered modules and explicitly supplied application factories; they are not discovered provider implementations.
use Clean\Framework\Module\ModuleCommand;
// Declare metadata before constructing optional services. The delegate must
// match the name and typed policy; the application's console enforces it.
$console->register(new ModuleCommand(
'reports:run', 'Run local reports.', $commandPolicy,
$modules, 'reports.export', $applicationCommandFactory
));Listing/disabled invocation never calls the factory. OFF returns UNAVAILABLE(4). A delegate cannot silently declare different policy. Direct raw execute() uses the actual snapshot environment, not implicit DEV; lock-requiring commands must use Application::run() with the application's explicit lock factory. That orchestrator enforces declared environments, parses required/typed input, acquires/releases locks, supplies deadlines and reports mutation classification. Classification is NOT operator permission or approval: handlers/use cases must still enforce their own consequential-action/PROD confirmation policies.
executeInput() is a trusted internal primitive, not a replacement console boundary. It checks deadline, snapshot environment, availability and delegate metadata, but accepts an already-created CommandInput: it cannot establish parser provenance, a held console lock, operator identity or mutation approval. Never expose it to HTTP/untrusted callers or invoke a lock-requiring command that way. Raw execute() without lock infrastructure fails rather than faking a lock. No public API break/lock-attestation contract is introduced here. The negative direct-input characterization is recorded, not advertised as prevented.
use Clean\Framework\Module\ModuleQueueWorker;
// Construct QueueWorker/stores/handlers inside this factory, only after admission.
$worker = new ModuleQueueWorker($modules, 'reports.export', $applicationWorkerFactory);
$report = $worker->run(['reports'], 100);Select dedicated queues, never mixed unrelated modules. The facade admits before construction and before every single-job claim, preserving existing scopes, lease ownership and outcomes. requestStop() prevents another claim and forwards cooperative stop. This facade is not a subtype of final QueueWorker: application typed commands call it explicitly. Wrapping ordinary QueueWorkCommand gives invocation admission only; use the facade for per-claim readiness checks.
The queue-to-module map is application-owned and must be enforced before calling the facade. ModuleQueueWorker cannot infer ownership from a queue name. For example, after constructing the wrapper with the same snapshot:
// A consuming app owns this closed mapping; never use a request-selected queue.
$allowedQueues = ['reports'];
$runReports = static function (array $queues) use ($worker, $allowedQueues) {
if ($queues === [] || array_diff($queues, $allowedQueues) !== []) {
throw new InvalidArgumentException('Mixed module queue selection is forbidden.');
}
return $worker->run($queues, 100);
};use Clean\Framework\Module\ModuleScheduleTask;
// ScheduleDefinition must set feature: 'reports.export'; ScheduleRunner must
// receive the same $modules FeatureGate. The target factory remains lazy.
$tasks['reports.task'] = new ModuleScheduleTask($modules, 'reports.export', $applicationTaskFactory);The runner skips disabled schedules before history/locks; the wrapper also denies direct calls and mismatched definition gates before factory invocation. Compose an active registry/health projection: ScheduleHealth does not automatically filter disabled schedules.
use Clean\Framework\Scheduler\ScheduleHealth;
use Clean\Framework\Scheduler\ScheduleRegistry;
// Retain every ungated required task; optional OFF must not silence failures.
$active = new ScheduleRegistry();
foreach ($registry->all() as $definition) {
if ($definition->feature() === null || $modules->decide($definition->feature())->enabled()) {
$active->register($definition);
}
}
$health = new ScheduleHealth($active, $history, $clock);Build this application-owned projection from the same snapshot as ScheduleRunner; it does not change global ScheduleHealth semantics. Rebuild on restart, or at each readiness check if the application uses mutable readiness metadata. An ON task without success remains never_succeeded; overdue success remains stale.
Disablement, restart and UNKNOWN
Choices are immutable per process; editing a JSON file does not revoke old ON workers. More precisely, each composition creates a snapshot. The classic PHP front controller recomposes per request; long-running kernels/CLI/workers retain their snapshot until restarted. Publish complete private preference files atomically and coordinate the target's reviewed code/config restart policy; no existing object is mutated live. No preference cache or hot reload is supplied. Coordinate stop-new-intake → requestStop/drain → persist/reconcile admitted work → restart from one compatible code/schema/policy set. No live hot reload, asynchronous process interruption or automatic domain mutation is supplied.
| Disposition | Application/operator responsibility |
|---|---|
| complete | Finish an already admitted safe attempt, then stop intake |
| pause | Persist a resumable safe checkpoint without guessing effects |
| cancel | Request durable cooperative cancellation with valid ownership/leases |
| reconcile | Hold and consult authoritative effect/receipt state before retry |
inFlightDisposition(module, outcomeUnknown:true) ALWAYS returns reconcile. Dispositions are instructions, not automatic queue/outbox writes. The facade drains admitted attempts, not forcible PHP pause/kill; handlers/operators own safe checkpoints. UNKNOWN is never reset/deleted/replayed/retried or cancelled as success by this layer. Pending work/canonical references survive OFF restart.
Adoption, aliases and boundaries
Existing FeatureGate/execution signatures are unchanged; the owner-approved load() file contract migrates PHP preferences to JSON. Opt-in adoption is required at routes, CLI, workers, schedules AND service entry points. Direct old unguarded APIs do not become protected automatically. A legacyBindings map can temporarily map a module to an old generic env flag; selected prefixes retain precedence. Simultaneous effective old/new bindings fail even if equal. Status lists only consumed deprecated variable names. Remove the alias after migration; do not retain a second independent boolean check beside ModulePolicy.
Service adoption and cutover checklist
- Register an explicit catalog; migrate only this app's preferences to JSON. Keep no executable fallback or competing preference file. Validate every environment; credentials cannot request activation.
- Share one
ModulePolicyinstance through HTTP container composition, CLI, worker/scheduler wrappers AND independently callable optional services. Require Config/module environment equality. Across separate processes compare canonical environment/settings and deployed code/config identity; object IDs prove identity only inside one process, not a cross-process policy version. - Check availability inside each optional service before constructing adapters or starting effects. Old unguarded APIs stay callable when OFF: explicit adoption is required, not universal interception. Authentication, object authorization, CSRF and consequential-action approvals remain separate.
- Use the console orchestrator, validated dedicated queues and active schedule health without filtering mandatory ungated failures.
- Stop intake, drain attempts, retain/reconcile UNKNOWN, atomically publish compatible code/config and start new immutable process snapshots. An old ON worker stays ON after JSON changes until explicitly stopped/replaced.
- Preserve pending work and canonical references. UNKNOWN always requires authoritative application-owned reconciliation before any new action; no generic queue UNKNOWN reset/resolution API is supplied. A fixture's synthetic reconciliation record is not a provider reconciliation implementation.
The installed neutral assurance uses two disposable Composer consumers, four environments and ON/OFF restarts. It does not qualify the real KB or any other consumer. Real isolated SQL lease/drain proof uses synthetic local effects, not a provider or production process supervisor.
Rollback only with compatible workers/data/intents. No consumer/KB dependency, migration, VERSION, original phase result or acceptance gate changes here. Tests cover schema/env/readiness/lazy denial, independent auth, controlled drain/OFF restart/UNKNOWN retention, schedule/CLI and real FPM DEV-versus-STAGE/ PROD plus built-in-server smoke. Run approved cached verify/quality/Apache gates; missing tools/failing assertions are not PASS. These are not live adapter, native scanner, distributed policy, human-review or deployment qualification.
Source-bound local proof is in docs/evidence/MOD-002-implementation-2026-10-17.json: 21 focused cases,6 installed CLI cases,116 Apache/development checks and pinned verify/quality gates pass with owned cleanup. This is not live-adapter approval.
Later corrective proof: Phase A covers readiness identity/method concealment; Phase B covers health/JSON/adoption/lifecycle/bounds. Original feature evidence above is historical, not a substitute for these later source-bound qualifications.
Editorial provenance
Source: docs/MODULE_ACTIVATION.md. Source SHA-256: 6c96c3bd383230cc95690ac9fccb39f49226ed7d160a9e8ed440d18d6e392799.
Framework candidate reference; editorial checkpoint 2026-10-20. See candidate status and adoption boundary.