ADR-0014: Exact dependency pinning and pre-1.0 isolation
- Status: accepted
- Date: 2026-08-01
- Deciders: Theurian maintainers
- Requirements: OSS-14, R-5, R-6, O-9, §10 of the brief
Context
Theurian is a daemon a user installs once and then forgets about for months. When it breaks after an unattended transitive upgrade, the user has no recent change to correlate it with, and the failure mode — a corrupted index or a failed migration — looks like data loss rather than a dependency problem.
Several dependencies are also young:
| Dependency | Version | Risk |
|---|---|---|
mcp |
2.0.0 | Major-version rewrite; FastMCP became MCPServer in 2.x |
sqlite-vec |
0.1.9 | Pre-1.0; on-disk format and API may change |
watchfiles |
1.2.0 | Post-1.0 but a small maintainer surface |
Decision
Pin everything
- Every runtime dependency is pinned with
==inpyproject.toml. No^, no~=, no>=. uv.lockis committed and CI runsuv sync --frozen, so a resolution change fails the build instead of silently landing in a release.- Upgrades are pull requests with a passing CI run — Dependabot proposes, CI decides, a human merges.
- The full dependency set is reported in the SBOM attached to every release (OSS-7).
Isolate pre-1.0 dependencies
- A pre-1.0 dependency may only be reached through a port (ADR-0003), and only
from the adapter that names it.
import sqlite_vecoutsideinfrastructure/vector/is a lint failure. - Every pre-1.0 dependency has a documented exit path — for
sqlite-vec, an in-tree brute-forceVectorStorethat is correct but slower, kept working and tested so it is a configuration change rather than a project. - Adopting a new pre-1.0 dependency requires an ADR naming the port that contains it and the fallback if it is abandoned.
Contain the young major
mcp2.x is confined tomcp/anddaemon/. Application and domain code never imports it.- Contract tests assert the wire protocol — an HTTP request against a running daemon — not SDK call signatures. An SDK API change then breaks one adapter, not the test suite's meaning.
Split the install
- Optional extras (
daemon,vector,telemetry) keeppip install theuriansmall for CI images and pre-commit hooks that only need the CLI and the migration engine.theurian[all]is the developer install.
Verify versions against upstream
- Every pinned version in this repository was checked against the package index at pin time rather than recalled. The MCP 2.0 API surface used here was verified against the installed package, not only against documentation.
Consequences
Positive
- A given Theurian release behaves identically everywhere it is installed.
- A dependency-caused regression is isolated to one merged upgrade pull request.
- A pre-1.0 dependency's breakage is confined to one adapter with a tested fallback.
- Reproducible builds (OSS-14) are a property of the committed lock file.
Negative
- Security patches require an explicit upgrade rather than arriving on the next install. Mitigated by Dependabot, and by the fact that this is a local daemon with no untrusted network exposure.
- Pinning can conflict with another package in a shared environment. Mitigated by
recommending
uv tool install --python 3.13 'theurian[daemon]'orpipx install --python 3.13 'theurian[daemon]', which isolate it. Spelled here the waydomain/extras.pyspells them, flag included:3.13is therequires-pythonfloor, and the flag is what stops the installer resolving against whichever interpreter it would otherwise pick. - Decision 10's split makes the bare install a Theurian whose daemon cannot
start, and that cost went unrecorded here until it reached users.
uv tool install theurianresolves, installs, and then fails at the next step of the documented flow withModuleNotFoundError: No module named 'uvicorn'(#78). The split is kept — a CI image that only runstheurian migrateshould not carry a web server — so the cost is paid where it belongs: every surface that instructs an install namestheurian[daemon], andcore-presentnow refuses an install without it instead of reportingsatisfiedand going on to register a service that cannot start. The extras a surface must not name arevectorandtelemetry: measured,sqlite_vecandopentelemetryare imported nowhere insrc/, and twouv tool installruns into separate tool directories differ by 12 distributions —certifi,charset_normalizer,googleapis_common_protos, fiveopentelemetry_*,protobuf,requests,sqlite_vec,urllib3— and no behaviour.
Neutral
- Pins are floors and ceilings simultaneously, so the upgrade cadence is a deliberate maintainer activity rather than an emergent one.
Alternatives considered
| Alternative | Why rejected |
|---|---|
Compatible-release ranges (~=) |
A transitive minor upgrade breaks an installed daemon with no correlating user action. |
| Lock file only, ranges in metadata | The lock protects contributors; PyPI consumers get the ranges, so the guarantee does not reach users. |
| Vendor pre-1.0 dependencies | Inherits the maintenance burden and diverges from upstream fixes. |
| Avoid pre-1.0 dependencies entirely | sqlite-vec has no mature alternative for embedded vector search; the port plus fallback is the proportionate control. |
Compliance
security.yml'spinningjob, step "Every dependency uses==", fails if any dependency in eitherpyproject.tomlis unpinned. This section called it a test; it is a workflow step, and it runs on every push and pull request. The distinction is not pedantry — it does not run underuv run pytest, so a contributor who checks locally the wayCONTRIBUTING.mddescribes will not see it fail.- CI runs
uv sync --frozen. tests/unit/test_layering.py::test_volatile_dependencies_are_confinedkeepssqlite_vecinsideinfrastructure/andmcpinsidemcp/anddaemon/, by walking the import graph. This section called it "a banned-import lint rule" and namedinfrastructure/vector/as the boundary. Neither was right:pyproject.toml's[tool.ruff.lint.flake8-tidy-imports.banned-api]has a single entry,theurian.infrastructure(ADR-0003), and the boundary the test enforces is the wholeinfrastructurepackage.
Still owed, with the milestone that will satisfy it:
- There is no
VectorStoreconformance suite, and nosqlite-vecadapter. This section claimed a suite running against both that adapter and a brute-force fallback.infrastructure/vector/contains only__init__.py,sqlite_vecis imported nowhere insrc/, and the vector search that ships is an exact scan insideSqliteIndexStore.search_dense— deliberately, per ADR-0021, because a local corpus is small enough that an exact scan is both fast enough and reproducible. So the second implementation this item exists to compare against has not been written and may never be. The item comes due with the first ANN adapter; until thentest_volatile_dependencies_are_confinedis guarding an import that does not happen.