ADR-0008: Controller decomposition + composition-root finalisation¶
Status¶
Accepted. Implemented in EPIC #2046 PR 3 (issue #2049), building on the substrate from ADR-0007.
Context¶
ADR-0007 landed the feature-manifest substrate (discover_features(),
FeatureManifest, topological ordering, slice composition) and the
per-feature feature.py manifests. But the substrate was only
half-consumed. api/app.py still hardcoded controllers in a 62-entry
BASE_CONTROLLERS tuple, the MCP registry and dispatch table merged 20
hand-listed tuples and dicts, and several controllers had grown into
god-modules:
| Module | LOC |
|---|---|
api/controllers/providers.py |
1594 |
api/controllers/memory.py |
1369 |
api/controllers/settings.py |
1086 |
auth/controller.py |
940 |
api/controllers/setup_controller.py |
938 |
api/controllers/webhooks.py |
936 |
api/controllers/approvals.py |
893 |
meta/mcp/handlers/infrastructure.py |
1550 |
meta/mcp/handlers/communication.py |
976 |
These were baselined under ADR-0006's tiered module-size policy (Section B of the EPIC exemption ledger), pending this PR.
Decision¶
Per-sub-domain controller packages (no re-export bag)¶
Each god-controller becomes a package of per-sub-domain controller
modules, every module carrying # module-kind: controller (400-LOC
cap). Cross-sub-domain helpers and DTOs live in a _shared module, not
duplicated. The package __init__ stays a docstring-only marker: there
is no __all__ re-export bag, because a re-export bag hides the surface
the substrate is meant to expose. Each sub-controller is claimed by its
co-located feature.py manifest's controllers tuple. Multiple
controllers may share a Litestar path; their routes merge.
The two oversized MCP handler modules decompose the same way into
meta/mcp/handlers/{infrastructure,communication}/ packages, one module
per sub-domain, each exporting its <DOMAIN>_HANDLERS map. The package
__init__ aggregates the sub-maps into the domain's canonical handler
map. The domain tool-definition modules stay under cap and are not
split.
Substrate extensions consumed by the composition root¶
This PR extends ADR-0007's accepted frozen descriptors:
ControllerRegistration: a controller plus an optional readinesspredicate(evaluated against the liveAppStateat route assembly) and amountpoint ("api"or"root"). A bare controller class is equivalent to an unconditional api-mounted registration. The predicate preserves the historic 404-when-unwired behaviour for integration and optional controllers, so a disabled subsystem 404s rather than 503-ing every dashboard poll.websocket_handlers: the real-time@websockethandler is a function, not aController;api_core's manifest declares it here.McpHandlerDescriptor.tool_defs+handlers_factory: the feature'sMCPToolDeftuple plus a deferred handler-map loader. Both MCP aggregations (build_full_registry,build_handler_map) iteratediscover_features()instead of the hand-listed tuples. The loader is deferred (a callable, not an eager map) so importing afeature.pyduring discovery never pulls the service-heavy handler graph.ServiceLifecycleHook: extendsLifecycleHookwithstop. Used for feature services only.construction_wirer: a per-featurewire_construction(app_state, deps)callable discovered and run independs_onorder during the construction phase (before persistence connects). The fifteenswap_slice(model_construct(...))blocks that the oldapp.pyran inline became feature-owned_construction.pywiring functions, threaded a frozenConstructionDepsbundle. Adataclass(not Pydantic) so nesting thePhase1Result/MeetingWireResultNamedTuples does not trip Pydantic forward-ref introspection.
A small meta/mcp/feature_descriptors.mcp_descriptor helper pairs the
eager tool-def tuple with the deferred loader so each manifest declares
its MCP surface in one call.
Core scaffold stays hand-written¶
The persistence, bus, task-engine, meeting, backup, settings-dispatcher,
and distributed-backend startup and shutdown sequence is heterogeneous:
per-service timeout budgets are an orchestrator contract, shutdown is
not pure reverse order, and task-engine couples its inner stop timeout
with the outer cancel. This core scaffold stays a hand-written ordered
table run by one generic runner that tracks started services and cleans
up in reverse; it is NOT migrated to the ServiceLifecycleHook
protocol. Only feature services use the hook dispatcher. Unifying
the core infrastructure would lose the budgets and coupling and is the
central risk this decision deliberately avoids.
Conditional and latent-dead controllers¶
Optional-controller predicates evaluate at construction, where the route list freezes. Objective and Brownfield controllers depend on a work-entry adapter that is wired in a startup hook, after route assembly, so on the standard boot path they never register. The migration preserves this latent-dead status and locks it with a test rather than silently registering them.
Section B exemption lifts¶
Bringing the centre under its caps lifts ADR-0006's Section B exemptions:
- God-module allowlist drained.
api/app.py(a 147-LOC discovery-based composition root),api/state.py,api/auto_wire.py,api/lifecycle.py, andapi/lifecycle_builder.pyall fall under their code-tier cap, so they drop fromcheck_no_growth_in_god_modules.py's net-shrink allowlist and are governed bycheck_module_size_budget.py.core/enums.pywas dissolved and deleted by #2051; the allowlist is now empty. - Mypy narrowed via specific override precedence. A more-specific
override beats the
synthorg.api.*wildcard, so the composition root, the lifecycle runners, and the Any-clean decomposed controller packages (providers,memory) hit the full strict++ bar. Modules that inherited the work-pipelineWorkEntryAdapter[Any]boundary (same asapi/state.py) or other explicit-Any kept thedisallow_any_explicitexemption at decomposition time (the project-wide Any drain was #2056) but re-enabled the in-scopeunused-awaitablecheck. EPIC #2056 has since completed: nosynthorg.*disallow_any_explicitexemption remains. - Ruff DOC enforced. The five ex-god-modules drop their
DOC201/202/501per-file-ignore and carry full docstrings. TheBLE001blind-except drain stays #2062; residual function-complexity is governed by the module-size tier budget.
Consequences¶
- Locality: adding or changing a feature touches one directory. The composition root reads the manifests; there is no central controller list or MCP tuple to edit.
- AI-navigable:
data/feature_index.jsonrecords every feature's controllers and MCP tools from the manifests. - Negative: the PR footprint is large, and the substrate descriptors now carry richer (controller, tool-def, loader) contributions.
Alternatives considered¶
- An
__all__re-export bag per package: rejected, hides the surface the substrate exposes and re-creates a central list at the package boundary. - A central controller registry object: rejected, a new god-object.
- Uniform discovered lifecycle hooks for core infrastructure: rejected, loses the hand-written shutdown budgets and task-engine coupling.
Related¶
- ADR-0006 (tiered module-size policy): Section B of its exemption ledger is lifted by this PR.
- ADR-0007 (feature-manifest substrate): this PR consumes and extends it.
- EPIC #2046; issue #2049.