ADR 004: Core Packages Do Not Import the Web App¶
Date: 2026-08-02 Status: Accepted Context: Issue #71 (part of #61, Modularity)
Decision¶
metaseed.ui is a host, not a library layer. No module outside metaseed.ui imports metaseed.ui, at module level or inside a function body, except two declared seams:
| Seam | Why it exists |
|---|---|
metaseed.agent.mcp.ui_session |
The MCP host edits the same local editing session as the web UI. |
The ui command in metaseed.cli.app |
The CLI launches the web app; that is what the command is for. |
Everything else — metaseed.specs, metaseed.models, metaseed.facade, metaseed.validators, metaseed.dcat, metaseed.repositories, metaseed.api.client, metaseed.cli, the rest of metaseed.agent — depends only downward.
Context¶
A consumer embedding metaseed's tools should not pay for FastAPI. Two edges broke that:
metaseed/cli/migrate.pyimportedget_datasets_dirfrommetaseed.ui.datasetsat module level.metaseed/ui/__init__.pyeagerly importedmetaseed.ui.app, so importing a dataset-migration CLI command constructed the entire web application (fastapi,starlette,metaseed.ui.appall insys.modules).metaseed/repositories/memory.pyannotatedMemoryEntityRepositorywithAppStateandTreeNodefrommetaseed.ui.state. The import wasTYPE_CHECKING-only, so it cost nothing at runtime, but the data layer still named the UI as its type.
A third group — roughly 25 imports under metaseed/agent/mcp/ — is not the same defect. Those reach for AppState, EntityService, DatasetManager, DatasetManagerFactory and the metaseed.ui.datasets helpers because an MCP tool and a web route edit one session: a tool call and a browser click must land in the same dataset. That dependency is real. Hidden inside function bodies it was invisible to an import-graph linter and indistinguishable from the two defects above.
Considered Alternatives¶
Alternative 1: Move the session layer (AppState, EntityService, DatasetManager, dataset helpers) out of metaseed.ui¶
The honest long-term shape: this layer is framework-free and has two hosts.
Rejected for now because: metaseed.ui.state alone has 85 references across src/ and tests/, the test suite patches module attributes by path (metaseed.ui.datasets.…), and metaseed-hub imports these names through its own boundary module. The move is a separate, larger change; doing it inside this one would have meant rewriting tests that must stay untouched to keep proving the behaviour.
Alternative 2: Leave the MCP imports inside function bodies¶
Rejected because: a coupling a linter cannot see is a coupling that grows. The cli/migrate.py edge was found only by importing modules one by one in a fresh interpreter.
Alternative 3: Hoist all ~25 MCP imports to module level, each naming its own metaseed.ui.* module¶
Explicit, but it spreads the seam over eight files and makes the guard's exception list eight entries long.
Consequences¶
get_datasets_dir()lives inmetaseed.paths, next toget_user_data_dir()andget_user_specs_dir(). It resolves throughmetaseed.repositories.filesystem_dataset.default_datasets_dir(), soMETASEED_DATASETS_DIRand the repository now agree on one directory; before, the migration CLI ignored the override the repository honoured.metaseed.repositories.basedefinesEntityTreeStateandEntityTreeNodeprotocols describing the stateMemoryEntityRepositoryreads.AppStateandTreeNodesatisfy them structurally; the data layer no longer names the UI.metaseed/ui/__init__.pyresolvescreate_appandrun_uion first attribute access (PEP 562). Importing a leaf module such asmetaseed.ui.stateno longer constructs the FastAPI application, so the MCP host can hold a session without a web server. The ASGI application object is no longer re-exported from the package: onmetaseed.uithe nameappis the submodule, and a lazily resolved export under the same name would depend on who imported first.metaseed.ui.app:appis unchanged and remains the ASGI target.metaseed.agent.mcp.ui_sessionre-exports the session names the MCP host uses. Its imports are module level: the dependency is declared once, in one file, and shows up in an import graph.metaseed.ui.datasetsis re-exported as a module (ui_datasets) rather than as individual functions, so calls still resolve through the defining module exactly as the function-level imports they replace did.tests/test_modularity.pyis the gate. It walks the AST of every module undersrc/metaseedoutsidemetaseed/ui, catching function-level andTYPE_CHECKINGimports, and names file and line for every offender. It also imports each core package in a fresh interpreter and fails iffastapi,starletteormetaseed.ui.appreachessys.modules— a transitive leak an AST scan cannot see.
metaseed.agent.mcp.server still loads starlette: mcp.server.fastmcp imports it. That is the MCP SDK's dependency, not a metaseed edge, so the guard checks the MCP host for fastapi and metaseed.ui.app only.