Import layering¶
SynthOrg enforces its module-dependency rules with two complementary mechanisms: declarative contracts checked by import-linter and three codebase-specific AST gates. Together they cover the shapes of layering badness that matter here without pretending the architecture is a clean total order.
Declarative contracts (.importlinter)¶
import-linter (backed by grimp) builds the full static import graph
of synthorg and checks the contracts in the repo-root .importlinter
file. Run it locally with:
It runs as a pre-push hook (lint-imports in .pre-commit-config.yaml)
and as a step in the Lint CI job.
The contracts are reality-grounded: each one passes against the current import graph and exists to stop regressions, not to describe an aspirational layering. The current contracts are:
- core-is-foundation --
synthorg.corehas no import path (direct or indirect) up toapi,persistence,engine,workers,meta,tools,providers,communication,budget, orconfig.coreis the bottom layer; everything may depend on it, it depends on nothing above it. (securityis intentionally absent:core.companyreads a small set ofsecurityconfig models, a deliberate pre-existing edge.) - execution-is-a-leaf --
synthorg.execution(the light leaf holdingTurnRecord, the trajectory enums,EfficiencyRatios, theExecutionResultViewprotocol, andParkedContext) has no path up toengine,api,workers, ormeta.enginedepends downward on the leaf; the leaf never reaches back intoengine. See ADR-0012. - persistence-app-boundary --
synthorg.persistencedoes not directly importapiorworkers. Domain-model imports intoengineandmetaare legitimate (repositories serialise those models) and are not forbidden. - observability-below-api --
synthorg.observabilitydoes not directly importapi.
Why direct-only, and the ignore lists¶
The app-boundary contracts set allow_indirect_imports = true, so they
check only direct imports. The codebase routes many cross-subsystem
references through shared hubs: every per-domain *.state slice imports
api.state_slices, and config.schema transitively reaches most
subsystems. Those transitive paths are unavoidable and not meaningful as
layering violations, so the contracts target the direct edges that are
meaningful.
A small number of deliberate direct back-edges are blessed in each
contract's ignore_imports:
- the construction-wiring seam (
persistence._construction -> api.construction_wiring/api.state), - the per-feature state slice (
persistence.state -> api.state_slices), - the shared system user (
persistence.{sqlite,postgres}.user_repo -> api.auth.system_user), - the metrics collector reading app state
(
observability.{prometheus_collector,startup_wiring} -> api.state,observability.feature -> api.controllers.metrics).
Why no total-order layers contract¶
A strict layers contract enumerates a top-to-bottom order and forbids
every upward edge. The real graph keeps deliberate back-edges (the state
slices and system_user above; core <-> observability for the logger
seam) that a total order cannot express without a sprawling
ignore_imports list, so we use targeted forbidden contracts instead.
Custom AST gates (not replaced by import-linter)¶
Three gates check rules that are about how an import is used, which a graph-level tool cannot see:
scripts/check_persistence_boundary.py-- onlysrc/synthorg/persistence/may importsqlite3/aiosqlite/psycopgor emit raw SQL. The real persistence boundary is about driver and raw-SQL access, not package-level import direction.scripts/check_no_api_dto_in_persistence_or_service.py-- API DTO modules must not be imported insidepersistence/or service layers.scripts/check_dependency_inversion.py-- callers depend on repository protocols, not concrete backend classes.
Cold-import smoke test (tests/unit/test_cold_import.py)¶
The declarative contracts cannot see the worst class of import bug this
codebase has hit: a cold-import cycle driven by eager re-export side
effects in a package __init__, not by a simple module-to-module edge.
Importing any submodule runs its package __init__, and a hub __init__
that eagerly re-exports its implementation pulls a large subgraph as a
side effect. grimp builds its graph from explicit import statements,
so it reports such a contract KEPT even when a fresh-interpreter import
of the leaf raises ImportError (see
ADR-0012).
The regression guard is therefore a runtime smoke test: each leaf in
COLD_IMPORT_LEAVES is imported in its own freshly spawned interpreter
via subprocess, which is the only faithful way to assert a cold
import (within the pytest process the graph is already primed by
tests/conftest.py). The forbidden contracts above are a secondary,
documentation-grade backstop.
To keep a leaf cold-importable, do not add eager implementation
re-exports to a package __init__ on its import path; keep hub inits to
light abstractions (config / models / protocols) or empty, and place
genuinely shared types in a light leaf package (core.* or
execution.*) rather than reaching up into a heavy hub.
Changing the contracts¶
- Adding a contract: add a
[importlinter:contract:<id>]block, runlint-imports, and confirm it reportsKEPT. A contract that does not pass today is a code change, not a config change. - Blessing a new back-edge: add the exact
module -> moduleline to that contract'signore_imports. Prefer fixing the import; bless only edges that are genuinely intended (a wiring seam or shared singleton).