ADR-0007: Kernel layers and an acyclic kernel, enforced by import contracts¶
- Status: Accepted
- Date: 2026-10-25
- Deciders: Sandloom maintainers
- Complements: the module ↔ kernel boundary (
architecture_rules.yml,sandloom check) and ADR-0005 (the effect model, whose commit pipeline theunit_of_workpackage implements)
Context¶
The boundary between business modules and the kernel (sandloom.core) was
strictly enforced; the kernel's own structure was not. Measured on main
3e659852 (228 kernel modules, 74k lines):
- Cycles were hidden, not absent. The module-level import graph looked
acyclic only because cycles went through function-local imports
(550
# noqa: PLC0415markers insandloom/core, 616 in the tree). Counting every runtime import, one strongly connected component held 96 modules (42 % of the kernel). - Even the module-level imports, grouped by top-level kernel package, formed
one 11-package cycle (eventing, facade, infra, loader, migrations, orm,
planner, projections, sql_alchemy, tasks, unit_of_work) — 27 packages once
the namespace packages (
tasks,sql_alchemywithout__init__.py) became visible to the analyser. - Five files of 1 300–1 900 lines sat in that knot:
unit_of_work.py,idempotency.py,facade.py,sql_alchemy/planner_adapter.pyandsql_alchemy/outbox_store.py.
A lazy import does not remove a dependency, it hides it from the interpreter. Without a map nobody can say whether a new import goes "down" or "up", so the knot grows one reasonable-looking import at a time.
Principles¶
- Acyclic Dependencies (ADP). No cycle between kernel packages or modules, at any depth. A cycle means the parts cannot be understood, tested or replaced separately.
- Stable Dependencies (SDP). Depend in the direction of stability: configuration and foundation types change least and sit at the bottom; composition and tooling change most and sit at the top.
- Dependency Inversion (DIP), sparingly. A lower layer may expose a hook a higher one fills — only where there is a genuine extension point, each one justified below. A Protocol or hook that exists only to break an import is an artificial abstraction and is not used.
- Composition root. Concrete backends (
drivers) are chosen and built by the composition layer (app_factory,composition, the worker), never reached from below. - Extract shared types downwards. When two layers need the same value (a ContextVar, an exception, a URL helper), it moves to the lowest layer that both may import, instead of one importing the other.
- Never hide a cycle behind a function-local import. A function-local import in the kernel is legitimate only for an optional dependency, or to keep import cost / import-time side effects off the application's import (CLI, test harness, a driver SDK, metric registration). It states which (see "Function-local imports").
Decision¶
- The layer map is an import-linter
layerscontract (.importlinter, contractkernel-layers):containers = sandloom.core,exhaustive = true, so a newsandloom.core.*child fails until it is placed. Every runtime import counts, module-level and function-local; onlyif TYPE_CHECKING:bodies are excluded (exclude_type_checking_imports). - The kernel is acyclic at every depth — contract
kernel-acyclic(acyclic_siblings,ancestors = sandloom.core,depth = 20): siblings of one layer (:in the map) may import each other, but never in a cycle, and no sub-package (unit_of_work.*,facade.*, …) may have one either. - Configuration is a leaf — contract
kernel-config-leaf(forbidden):sandloom.core.config(settings, tiers, accepted risks, password parameters, replay limits, the profile names) imports no other kernel package. - Enforcement.
lint-imports(import-linter, pinned exactly in the dev extras anduv.lock) runs fromtests/architecture/test_kernel_import_contracts.py(which also refuses anignore_importsentry without a; reason:comment) and from theimport-contractspre-commit hook. The earliersandloom checkrulecore-layersand its baseline were removed: nosandloom checkrule duplicates what the tool checks. - No
ignore_imports. No contract has one. Should one ever be needed it is debt, not configuration: a written reason and an owner right above the entry; import-linter fails an entry that no longer matches.
The map (top → bottom)¶
| layer | contents | why here |
|---|---|---|
| tooling | cli, testing, contract_testing, archcheck |
operator CLIs (incl. sandloom-migrate: cli.migrate, cli.extract) and the test harness may reach everything |
| composition | app_factory, admin, composition, worker, gc_tuning, guarantee_windows, di, lifecycle, facade |
wiring: the composition root, process entry points, startup checks, the public DI surface and the CoreFacade aggregator |
| contracts & RPC | rpc, loader, module_api, contract_compat, contract_cache_shadow, rpc_transport, rpc_transport_compat |
sync contracts, the module loader and the internal RPC client/server; above HTTP because the RPC server is an HTTP endpoint and contracts map HTTP errors |
| auth & HTTP | http, idempotency, operation_classes, auth, internal_auth, production_profile, route_markers, guard_order, rpc_binding, rpc_identity, rpc_pair_mac, ingress |
tokens, revocation, request guards and the HTTP pipeline; ingress holds the primitives the middlewares and guards share (client IP, rate limiter) |
| drivers | _internal, drivers |
concrete backends implementing the infra contracts with the persistence and messaging machinery; drivers.bootstrap builds the registry |
| messaging | projections, tasks, async_runner, beat_lease, consumers |
everything that runs a handler or task in a Unit of Work: the consumer runtime, the outbox relay and event operations, projections, the worker loop |
| persistence | migrations, orm, sql_alchemy, planner, unit_of_work, tenant |
the Unit of Work and its commit pipeline, the relational paradigm, the planner, the tenant guard, the migration runner |
| bus | eventing, runtime |
the event bus, transports, envelopes, delivery primitives, consumer inbox; runtime (active core) resolves the app-owned bus. Below persistence because the commit pipeline publishes |
| infra | infra |
capability contracts, the registry, the access layer (infra.access.cache()) |
| foundation | exceptions, tenant_context, contract_context, observability, signing_keys, contract_wire, effects, descriptors, contracts, schema, claims, utils, cpu_quota |
primitives every layer may read: context vars, the wire codec, effect records, descriptor and contract types, logging/metrics/tracing |
| configuration | config |
settings and the value types it validates — a leaf |
sandloom.core.auth (tokens, bearer, revocation marks, blocks, freshness,
recovery) and sandloom.core.config (the settings split) are the packages of the
auth/config split; auth imports nothing above the auth & HTTP layer, and
config imports no other kernel package.
The one hook¶
| hook | why it is a genuine extension point |
|---|---|
unit_of_work.dispatch.set_committed_events_hook (armed by contract_cache_shadow) |
the shadow contract cache is an optional, off-by-default observer of committed events; the commit pipeline must not know it exists, and no other consumer of "events just committed" belongs in the UoW |
Every other cycle was removed by moving code to the layer it belongs to (see "What moved") or by merging two modules that were one unit.
The default-registry fallback was dropped¶
The last ignored edge was infra.defaults -> drivers.bootstrap: outside
production, di.cache() / di.notifier() with no registry bound quietly built
a default registry from the flat settings (built-in drivers, no plugins). No
test or example relied on it (with the fallback forced to raise, the full suite
and the three example apps still passed), so it was removed rather than
inverted into a hook — an inverted hook would have made the fallback depend on
which composition module had been imported. sandloom.core.infra.defaults is gone;
infra.access resolves through get_active_infra(), which raises in every
environment, with a hint naming the composition roots that bind a registry
(create_app, sandloom.core.composition.load_composition_core, the
sandloom.core.testing harness). The kernel has no import cycle at any level.
Function-local imports¶
A kernel # noqa: PLC0415 states its category, checked by
test_kernel_function_local_imports_state_their_category
(tests/architecture/test_lint_suppression_ratchet.py):
- optional: <reason>— a dependency that may be absent or unconfigured (sentry, OpenTelemetry, uvloop, prometheus multiprocess mode, probes of private driver internals whose absence is the reported fallback);- cold: <reason>— import cost or import-time side effects kept off the application's import: the CLI, test harness and migration tooling, driver SDKs (redis-py, asyncpg, Celery, pure-Python jsonschema) imported when the driver is built, the outbox relay (it registers its metrics on import),rpc_transport(it imports httpx).
A cycle break is not a category, and no kernel file is exempt. A hoisted kernel import
is written import sandloom.core.x as _x and used as _x.name: the lookup stays
late-bound, so a test patching sandloom.core.x.name behaves as with the old local
import.
Amendment (2026-10-25): a lazy import sits at a boundary. "Tooling path"
alone is not a reason: a module that only the CLI, the test harness or the
migration tooling imports already pays nothing on the app's import, so its
imports are module-level. A cold: marker names the boundary it keeps: the
pytest plugin's entry and the settings snapshot it must not trigger before
.env.test is loaded, a static command that runs unconfigured (sandloom check,
sandloom admin, sandloom add), gunicorn's master (it loads gunicorn_conf
before on_starting), the worker healthcheck, the sandloom.core.migrations
package __init__ (no Alembic for urls), or a heavy driver SDK on the app
path. Paying the CLI / harness / migration markers down took sandloom/core from
296 markers (274 cold, 22 optional) to 116 (94 cold, 22 optional);
NOQA_BASELINE["PLC0415"] (which also counts sandloom.shared's 8) went from 304
to 124. sandloom.main imports the same modules in the same time.
What moved (no re-exports at the old paths)¶
Only the documented public API keeps its import paths (sandloom.core.di,
facade, module_api, descriptors, orm, projections, testing,
contract_testing, sandloom.shared.*); public_api_baseline.json and the
contract snapshots are unchanged. Every importer (kernel, modules, examples,
templates, tests, docs) was updated.
| from | to |
|---|---|
infra/drivers/*, infra/bootstrap.py |
drivers/*, drivers/bootstrap.py |
eventing/consumers.py, eventing/runtime.py |
consumers/callbacks.py, consumers/runtime.py (with start_event_consumers) |
eventing/versioning.py, sql_alchemy/savepoints.py |
unit_of_work/versioning.py, unit_of_work/savepoints.py |
eventing/transport.py |
deleted (shim); PoisonEventError, stream_start_id, START_FROM_* in eventing/delivery.py |
migrations/migrations_cli.py, migrations/extract.py |
cli/migrate.py (sandloom-migrate entry point), cli/extract.py |
migrations/rls.py |
sql_alchemy/rls.py; to_sync_url in migrations/urls.py |
database.py |
deleted; Base in sql_alchemy/base.py (re-exported by the public orm) |
sql_alchemy/testing.py |
testing/database.py |
http/client_ip.py, http/rate_limit.py |
ingress/ |
loader/service_registry.py |
module_api/_service_registry.py |
TenantScope |
http/tenant_scope.py |
| request id ContextVar, contract-call ContextVars | observability/request_context.py, contract_context.py |
read guard, durable_inbox() |
module_api/_read_guard.py, sql_alchemy/durable_inbox.py |
| outbox / Streams operations of the CLI | tasks/event_ops.py (shared with the owner console) |
| cgroup helpers | cpu_quota.py |
Split along responsibilities, each a package whose submodules are the import paths:
| file (lines) | package |
|---|---|
unit_of_work.py (1 899) |
unit_of_work/{scope, hooks, dispatch, effects, savepoints, versioning} |
idempotency.py (1 690) |
idempotency/{__init__ (guard), fingerprint, replay, durable} |
facade.py (1 609) |
facade/{__init__, _state, _services, _claims, _registries, _runtime} (a base-class chain of CoreFacade mixins) |
sql_alchemy/planner_adapter.py (1 409) |
planner_adapter/{__init__, forms, compiler, units} |
sql_alchemy/outbox_store.py (1 266) |
outbox_store/{__init__, statements, relay, audit} |
Aliases removed: idempotency._ArmedKey, ingress.rate_limit._client_ip,
migrations.migrations.ensure_outbox_table, infra.config.CAPABILITIES,
consumers.callbacks.ALLOW_VOLATILE_CONSUMERS_ENV, and the package-level
re-exports of the five split packages, of module_api / facade privates, of
cli.events and of migrations.migrations.
Consequences¶
- A new upward import or cycle fails
lint-imports, the architecture suite and pre-commit, function-local or not. - Breaking a cycle needs a design move — move the code, extract the shared
type downwards, merge what is one unit — not a
# noqa: PLC0415. - Code that monkeypatches a kernel global patches the module that defines it (the split packages have no re-exports).
- Observable but intended: the logger names of moved modules follow their new
path (
sandloom.core.drivers.redis_cache,sandloom.core.drivers.sqlalchemy_database,sandloom.core.consumers.runtime); importingsandloom.core.diregisters the kernel's ORM models (it imports the relational UoW at module level now). Import-time side effects ofsandloom.mainare otherwise unchanged: the same third-party modules and the same Prometheus collectors are loaded.
Numbers (main 3e659852 → this change, after merging the auth/config split)¶
| before | after | |
|---|---|---|
| kernel modules | 228 | 281 |
| largest SCC, every runtime import | 96 | 0 (no cycle) |
| package-level cycles (top level / any depth) | 11-package (27 with namespace packages) | 0 |
# noqa: PLC0415, sandloom/core / tree |
550 / 616 | 265 / 332 (−284; kernel: 244 cold, 21 optional, none exempt) |
| largest of the five split files | 1 899 lines | 798 (idempotency/__init__.py), the rest ≤ 673 |
Cost — cachegrind instructions per request (benchmarks/ladder/cg, CG_K=200,
mean of two runs) and sandloom.main import time (median of 7), measured on the
layering change before the auth/config merge:
main 3e659852 |
this change | Δ | |
|---|---|---|---|
| HW7 | 8.157 M | 8.076 M | −1.0 % |
| L6W3 | 6.430 M | 6.372 M | −0.9 % |
| WLlf (login) | 13.690 M | 13.515 M | −1.3 % |
| R2.srv | 1.0713 M | 1.0511 M | −1.9 % |
import sandloom.main |
1.83 s | 1.70 s | −7 % (wall clock, noisy) |