Layers and import contracts
aizk has a layered architecture, and unlike most layered architectures this one is executable.
Two import-linter contracts live in [tool.importlinter]
in pyproject.toml, and they fail the lint gate the moment a package imports upward or reaches
past its allowed door. So the diagram below is not a wish, it is what the build enforces. This
page assumes you have read the System map and know roughly
what each package does.
Run the gate from the repository root after bootstrapping the frozen environment.
uv run --no-sync lint-importsCI runs the same check as lint-imports inside the lint job.
The stack
Section titled “The stack”Dependencies point strictly downward. A package may import anything beneath it and nothing above
it. Inside a layer, | siblings may not import each other at all, while : siblings may.
Three things in that picture are worth saying out loud.
mcp and api are independent of each other, so neither transport can grow a dependency on the
other and they meet only at memory, the one service both call. runtime sits above the
transports because it assembles them. And config is a leaf that imports nothing internal, which
follows from its independent bottom-layer placement rather than from a separate rule, so any
module anywhere can read settings without dragging the engine along.
Exhaustive means a new package fails the gate
Section titled “Exhaustive means a new package fails the gate”The layers contract sets exhaustive = true, which tells import-linter that the layers list
must name every top-level module inside the aizk container. Add src/aizk/newthing/ and the
contract does not quietly ignore it, it fails, and the only way to make it pass is to decide
which layer the new package belongs to and write it down.
Two other settings shape what counts as an import.
include_external_packages = true pulls third-party packages into the graph, which is what makes
the second contract below able to see sqlmodel and sqlalchemy at all.
exclude_type_checking_imports = true means an import guarded by if TYPE_CHECKING is invisible
to the contract. This is not a loophole so much as a recognition that a type-only import creates
no runtime dependency, and the codebase uses it deliberately. src/aizk/background/schedule.py
imports Runtime under TYPE_CHECKING even though runtime sits several layers above
background, because the worker is handed a runtime it never constructs.
The second contract, SQL stays where it belongs
Section titled “The second contract, SQL stays where it belongs”The other contract is a forbidden contract. Seventeen named packages may not import sqlmodel
or sqlalchemy at all.
aizk.api aizk.auth aizk.client aizk.cliaizk.common aizk.commands aizk.exceptions aizk.integrationsaizk.mcp aizk.memory aizk.provenance aizk.runtimeaizk.serving aizk.storage aizk.status aizk.typesaizk.usageThose packages reach the database only through model classmethods and User.exec. The twelve
packages missing from that list are the documented exceptions, and they are exceptions for a
reason rather than by neglect. store owns the schema and the statements. admin, artifacts,
background, backup, export, extract, graph, ontology, ops, and retrieval each
compose their own statements, because a lane query or a maintenance sweep is easier to read
beside the code it serves than three files away. config types its statement hooks with
sqlalchemy.Select.
The contract also sets allow_indirect_imports = true, so only a direct import is forbidden. A
package in the list may still call something that itself uses SQLAlchemy, which is exactly what
User.exec is.
The test that keeps the split honest
Section titled “The test that keeps the split honest”A forbidden contract has the opposite failure mode from an exhaustive layers contract. Adding a new package does not break it, the package just falls outside both lists and gets to compose SQL with nobody noticing.
tests/test_contracts.py closes that hole. It reads pyproject.toml, takes the contract’s
source list, unions it with a hardcoded _SQL_COMPOSING set of the twelve exceptions, and
asserts two things.
assert sources | _SQL_COMPOSING == packages()assert not sources & _SQL_COMPOSINGThe first says the two lists together cover every top-level module actually present in
src/aizk/. The second says no package is in both. So a new package fails this test until an
author states, in writing, which side of the SQL line it sits on.
Where ruff picks up what import-linter cannot see
Section titled “Where ruff picks up what import-linter cannot see”An import contract sees imports and nothing else. It cannot tell that a transport built a statement out of symbols it imported for a legitimate reason.
Two ruff overlays cover that gap. src/aizk/mcp/ruff.toml and src/aizk/api/ruff.toml extend the
package configuration and use TID251 to ban sqlmodel.select, sqlmodel.Session, the async
session class, aizk.store.engine.Session, and aizk.store.engine.Database inside the two
transport packages, each with an error message naming the alternative. Repository-wide, the same
mechanism bans sqlalchemy.select in favor of sqlmodel.select so every query carries sqlmodel’s
more precise Select type.
| Check | Command | Catches |
|---|---|---|
| layer stack | uv run --no-sync lint-imports |
an upward import, an unassigned package |
| SQL contract | uv run --no-sync lint-imports |
a direct sqlmodel or sqlalchemy import outside the twelve |
| split coverage | uv run --no-sync python -m pytest |
a new package in neither list |
| call sites | uv run --no-sync ruff check . |
statement building or session opening inside a transport |
- Design principles explains the rules behind the contracts.
- Repository tour says what each package in the stack does.
- Style and typing covers the rest of the lint and type gates.