Skip to content
GitHub
Decisions

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:

  1. Snake-case noise in imports. Every internal import read from spectral_core.llm.purposes import PurposeKey, where the spectral_ prefix repeats in every file and every module name. The mixed suffix scheme (_core = role, _worlds = context, _scanning = context) added friction without benefit.

  2. src/spectral/<context>/ physical duplication. The “clean” version of the above (from spectral.core.llm import PurposeKey) required either build-backend source remapping (hatchling sources, setuptools package-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 has core in 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.worlds and spectral.scanning cannot import each other. spectral.core cannot 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/coresrc/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.toml was 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 sync installs one library + three apps as editable workspace members. Apps depend on spectral[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_DIRS paths 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/core boundary + governance framings; kernel now scoped to src/spectral/core/)
  • tools/quality/validate_architecture.py — structural enforcement
  • tools/quality/check_core_contract_tests.py — historical contract-requirement-test enforcement, retired in Phase 5 / M2; superseded by ADR-065 admission discipline enforced via tools/quality/validate_architecture.py rules 1–7 + ruff D100 / D104
  • tools/quality/check_no_namespace_init.py — preserves the future-split path