ADR-031: Single-library Python package with per-app leaf namespaces
Status: Accepted (2026-04-20; the third context was renamed from scanning to platform by ADR-041 D11 / SPEC-306 naming-coherence — the per-context role is spectral_platform_app, the schema is platform, and the ContentClass enum uses PLATFORM for customer content in the platform context)
Supersedes: ADR-001 on the import-package naming convention only (not on the three-context topology claim)
Context
ADR-001 established a three-package Python topology with workspace members at
packages/core, packages/worlds, packages/spectral importing as
spectral_core, spectral_worlds, spectral_scanning. During the Tech Arch
Review, two friction points surfaced:
-
Snake-case noise in imports. Every internal import read
from spectral_core.llm.purposes import PurposeKey, where thespectral_prefix repeats in every file and every module name. The mixed suffix scheme (_core= role,_worlds= context,_scanning= context) added friction without benefit. -
src/spectral/<context>/physical duplication. The “clean” version of the above (from spectral.core.llm import PurposeKey) required either build-backend source remapping (hatchlingsources, setuptoolspackage-dir) or physical nested layout (packages/core/src/spectral/core/). Remapping breaks editable-mode mypy resolution (setuptools’ runtime MetaPathFinder is invisible to mypy’s static analysis; hatchling’s prefix-adding remap is explicitly unsupported in editable mode per pfmoore/editables#20). Physical nesting hascorein two path segments. The Python ecosystem documents this as a real trilemma: per-package pyproject + flat src + working mypy — pick any two.
Primary-source survey confirmed: every major SDK that tries this (google-cloud-python, azure-sdk-for-python, Tweag reference, etc.) accepts the physical-nesting tax as the cost of per-package separation. No standard tool solves the trilemma.
A second observation unlocked the final decision: apps are the frameworks layer per Clean Architecture — nothing imports from them. Apps’ namespace choice is orthogonal to the library’s namespace choice, because no library code references app names.
Decision
The Python library collapses to a single package at repo root. Apps remain separate packages with snake-case leaf namespaces.
Library
/spectral/ (repo root)├── pyproject.toml # `spectral` library + optional-deps groups├── src/│ └── spectral/│ ├── core/ # contracts shared across contexts (spectral.core)│ ├── worlds/ # world-modeling context (spectral.worlds)│ └── scanning/ # scanning context (spectral.scanning)└── tests/ ├── core/ ├── worlds/ └── scanning/Imports: from spectral.core.llm import PurposeKey,
from spectral.worlds.domain.rules import Rule,
from spectral.scanning.application.scans import ScanService.
src/spectral/ intentionally has no __init__.py — it stays a PEP 420
implicit namespace so the future-split path (see the
library-split playbook) remains
open. Each context subpackage (core/, worlds/, scanning/) is a regular
package with its own __init__.py and py.typed marker. Enforced by
tools/quality/check_no_namespace_init.py.
Optional-deps groups on the library carry per-deployable dependency surface:
[project.optional-dependencies]api = ["fastapi>=0.115", "uvicorn[standard]>=0.34"]workers = []test_agents = []Apps pull their extras via dependencies = ["spectral[api]"] etc.
Apps
apps/├── api/│ ├── pyproject.toml # dependencies = ["spectral[api]"]│ ├── src/spectral_api/ # flat, snake-case, leaf namespace│ │ ├── main.py # FastAPI composition root│ │ ├── routers/ # HTTP interface adapters│ │ └── dependencies.py # DI wiring│ └── tests/├── workers/│ ├── src/spectral_workers/│ └── tests/├── test-agents/│ ├── src/spectral_test_agents/│ │ ├── shared/│ │ └── tax_prep/│ └── tests/├── dashboard/, operations/, docs-codex/, docs-user/ (TS, unchanged)Apps contribute snake-case top-level namespaces (spectral_api,
spectral_workers, spectral_test_agents). Snake-case is acceptable
because apps are leaves — their namespace only appears in entry-point
commands (uvicorn spectral_api.main:app) and intra-app imports that
never leak into the library.
Architectural invariants (enforced by tools/quality/validate_architecture.py)
- Library inter-context rules:
spectral.worldsandspectral.scanningcannot import each other.spectral.corecannot import either context. - Clean Architecture inside each context:
domain → application → infrastructure. - Apps cannot import other apps.
- The
spectral.*library cannot import from any app.
Kernel governance enforcement (packages/core → src/spectral/core/)
Replaced the physical-package-boundary mechanism with an automated
check: a pre-commit-tier lint rejected any commit that modified
src/spectral/core/ without a matching tests/core/test_contract_*.py
change. The contract-requirement-test discipline that the lint
enforced is now superseded by ADR-065 (admission discipline +
structural validator enforcement via tools/quality/validate_architecture.py
rules 1–7 + ruff D100 / D104); the lint tool was retired in Phase 5 / M2.
Alternatives considered
Physical packages/<context>/src/spectral/<context>/ per-package (Google/Azure
pattern). Accepts the path duplication to keep per-context pyproject.toml
- working mypy. Rejected: per-context
pyproject.tomlwas the weakest part of the trade — dep isolation doesn’t matter when the contexts share nearly all deps, and optional-deps groups on a single library provide cleaner per-deploy dep scoping. Kept as the documented fallback if splitting becomes necessary (see playbook).
Build-backend source remap (package-dir, hatch sources). Works
at wheel build time but breaks mypy --strict against editable
installs — setuptools’ runtime MetaPathFinder is invisible to mypy’s
static resolution; hatchling’s prefix-adding is unsupported in editable
mode. Abandoned after direct testing.
Apps contributing to spectral.* namespace via physical
apps/<name>/src/spectral/<name>/. Would keep dotted imports across
library and apps. Rejected: reintroduces the same path duplication for
apps, gains nothing because apps are leaves, and the snake-case leaf
(spectral_api) is equivalent in Pythonic legibility while avoiding
the nested structure.
Collapse apps into the library too (everything under src/spectral/).
Considered and rejected: apps are framework-layer deployables with
their own identity (Dockerfile, deploy configuration, entry-point
declaration, and optional own code). Keeping them separate matches
Clean Architecture’s frameworks-ring semantics and preserves the
pattern where apps/<name>/Dockerfile lives next to its deployable.
CODEOWNERS for packages/core governance. Considered; rejected
as theater in a solo-builder context. No other humans exist to route
review to. Replaced with the contract-test lint above.
Consequences
- ADR-001 partially superseded — the three-context topology claim stands
(three logical contexts enforced by validator); only the import-package
naming convention (
spectral_core/spectral_worlds/spectral_scanning) is replaced with the single-library dotted namespace + snake-case app leaves. - Build backend simplified. Hatchling with
packages = ["src/spectral"]for the library;packages = ["src/spectral_<app>"]for each app. No remapping, no editable-install surprises, no custom tooling. uv syncinstalls one library + three apps as editable workspace members. Apps depend onspectral[extras]. Deployment Docker images install the library with the right extras group.- Kernel governance enforcement moves from physical-boundary to contract-test-lint. Solo-builder-appropriate, automated, consistent with architecture-validator discipline at the time. ADR-065 later supersedes the contract-requirement-test discipline this lint enforces; tool retirement is sequenced alongside the validator’s new ruleset landing.
- Clean Architecture invariants unchanged. Domain → application →
infrastructure inside each context is enforced by the same validator; only
_BC_DIRSpaths changed. - Legacy
packages/core/,packages/worlds/,packages/spectral/directories are deleted.packages/hosts only TS libraries now (docs-core,design-tokens). The asymmetry between Python (single package at root) and TS (packages/ + apps/) is accepted — each ecosystem uses its idiomatic layout. - If we ever need to split the library into per-context Python packages, the fallback is the Google/Azure physical-nesting pattern — see docs/design/library-split-playbook.md. The playbook captures the decision rationale, triggers, and mechanical migration steps so a future engineer (human or AI) does not have to re-derive the path.
References
- docs/design/library-split-playbook.md — future-split escape hatch
- ADR-001 — partially superseded by this ADR on the naming convention
- ADR-065 — contract surface, admission rules, and payload pattern (supersedes the prior
packages/coreboundary + governance framings; kernel now scoped tosrc/spectral/core/) tools/quality/validate_architecture.py— structural enforcementtools/quality/check_core_contract_tests.py— historical contract-requirement-test enforcement, retired in Phase 5 / M2; superseded by ADR-065 admission discipline enforced viatools/quality/validate_architecture.pyrules 1–7 + ruff D100 / D104tools/quality/check_no_namespace_init.py— preserves the future-split path