Spec: F56 — Python 3.14 + uv + Granian ASGI platform upgrade
Status: 🚧 Source and repository proof covers the platform boundary; live, deployed, and production evidence remains open.
1. Problem & CEO Job
Job: Decide fast. The backend stack (Python 3.12, pip, gunicorn WSGI) was three separate drags on velocity and reliability. Python 3.14 unlocks free perf (no-GIL free-threaded mode, faster interpreter) and the latest ecosystem. pip was slow in CI and Docker builds (~45s cold installs per workflow). The WSGI+gunicorn+gthread bottleneck (8 concurrent slots) was identified in F55. This spec bundles them into one coordinated upgrade: Python 3.14 → uv for package management → Granian on ASGI for the server layer. For the CEO, the result is faster deploys, lower CI cost, and a backend that doesn’t saturate during peak hours — all without changing any application code.
2. User Stories & Acceptance Criteria
- As a CEO, I want faster CI/CD so that the team ships features faster.
- Given a clean CI run, when installing dependencies, then total install time is <15s (down from ~45s with pip).
- As a developer, I want one package manager for everything so that I don’t juggle pip/venv/pip-compile.
- Given a dev environment, when running
uv sync, then the virtual env is created and all deps installed in <10s.
- Given a dev environment, when running
- As a user, I want the app to stay responsive under load so that I never wait for a page while AI extraction runs.
- Given 30+ concurrent SSE connections and 10 simultaneous AI extractions, when all are active, then non-SSE API responses stay under 500ms (Granian ASGI, no gunicorn thread cap).
- As a developer, I want
config/asgi.pyto be the single entrypoint so that I can write async views without dual-maintenance.- Given the production server starts with
--interface asgi, whenconfig.asgi.applicationis loaded, then all existing sync views work unchanged.
- Given the production server starts with
3. Scope Boundaries
- In: Python 3.14 (free-threaded or default; decision below), uv replacing pip in Docker/CI/local-dev/CLI, Granian on ASGI replacing gunicorn, update all docs/diagrams/workflows, update
.python-versionandrequires-python. - Out: Rewriting sync views as async — all existing views work under ASGI (Django handles sync→async dispatch). Migrating from
requirements.txttopyproject.tomlonly (already dual-managed; this spec lockspyproject.tomlas the source of truth and removesrequirements.txtduplication). Adding any new infrastructure (caching layers, queues, read replicas).
4. Data Model
No models touched or added.
5. API Contract
The server bind, port, and health check remain identical (0.0.0.0:8000, /api/health/). The Contract Graph declares GET /api/health/detail/.
6. UI / UX
No user-facing changes. The app behaves identically — faster under load and faster deploys. Same routes, same UI.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | uv sync resolves and installs all deps | infra | Local dev |
| 2 | uv pip install works for ad-hoc packages | infra | CI |
| 3 | Django ASGI application loads under Granian | infra | granian --interface asgi config.asgi:application |
| 4 | All existing pytest suite passes under Granian ASGI | backend | cd backend && python -m pytest |
| 5 | Health endpoint returns 200 | e2e | POST /api/health/ |
| 6 | SSE streams survive under Granian ASGI | e2e | Manual: stream connects, events flow, reconnect on restart |
| 7 | Docker build succeeds with Python 3.14 and uv | infra | docker build . |
| 8 | CI workflow uses uv not pip | infra | CI passes |
| 9 | ASGI lifespan scope is answered, not crashed into Django | backend | backend/tests/test_asgi_lifespan.py (startup/shutdown complete; lifespan never reaches Django) |
| 10 | http / websocket scopes pass through the lifespan shim unchanged | backend | backend/tests/test_asgi_lifespan.py |
| 11 | config.asgi.application is wrapped in LifespanMiddleware | backend | backend/tests/test_asgi_lifespan.py::test_asgi_application_is_wrapped |
Granian ASGI runs Django’s WSGI-compatible views transparently, but it also emits the ASGI lifespan scope on boot/shutdown, which Django’s ASGIHandler rejects with ValueError. config/lifespan.py (LifespanMiddleware) answers the lifespan protocol and forwards every other scope unchanged. Covered by backend/tests/test_asgi_lifespan.py (5 cases, 100% of config/lifespan.py).
8. Current Behavior
Python
- Local: Python 3.12.11 (
.python-version) - Production:
python:3.12-slim(Dockerfile) - CI: Python 3.12 (setup-python)
- Declaration:
requires-python = ">=3.14"inpyproject.tomlanduv.lock
Package management
pipused everywhere: Dockerfile (pip install --no-cache-dir --prefix=/install -r requirements.txt), CI (8+ pip commands across 2 workflow files), local dev docs- Dual
requirements.txt+pyproject.toml— both must be kept in sync (already drift-prone) - No lockfile —
requirements.txtuses open-ended ranges
Server
- gunicorn with WSGI (
config.wsgi:application) --workers 2 --worker-class gthread --threads 4 --timeout 120- 8 concurrent request slots per ECS task
entrypoint.shlaunches gunicorn
ASGI
config/asgi.pyexists (standardget_asgi_application()) but is unused in production- No async views exist — SSE streaming uses sync generators with
StreamingHttpResponse
9. Error States
- Python 3.14 missing a dependency:
uv syncfails at resolution → check upstream compatibility; pin affected deps to a pre-3.14 version temporarily. - Granian ASGI breaks a sync view: Django’s sync→async wrapper handles this transparently for all WSGI-compatible views. If a view breaks, test with
--interface wsgifor isolation. - ASGI
lifespanValueError on boot: Granian emits thelifespanscope, which Django’sASGIHandlerrejects (ValueError: Django can only handle ASGI/HTTP connections, not lifespan.) — reported as an unhandled exception every boot (Sentry LUMI-BE-3). Fixed byconfig/lifespan.py(LifespanMiddleware), which answers the lifespan protocol and delegates all other scopes to Django. - uv not in Docker build cache: First build will download uv binary (~10MB). Cacheable via Docker layer or
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uv. - Rollback: Revert
entrypoint.shto gunicorn WSGI, revert Dockerfile topython:3.12-slim, revert CI to pip (worse — no gunicorn dep left in tree; Granian remains installable). No data migration needed. - Free-threaded Python crash: A C extension may not be thread-safe. Test with
PYTHON_GIL=1to re-enable the GIL. If issues arise, ship default (GIL-enabled) Python 3.14 and defer free-threaded to a follow-up.
10. Diagrams & Docs Touched
| Document | Change (shipped) |
|---|---|
docs/specs/F55-granian.md | Mark as superseded by F56 |
deploy/AGENTS.md | Replace all pip/gunicorn/WSGI references with uv/Granian/ASGI |
deploy/DIAGRAMS.md | Update architecture labels (ASGI, uv) |
DIAGRAMS.md | Update backend subsystem label |
backend/entrypoint.sh | Replace gunicorn with granian --interface asgi config.asgi:application |
backend/Dockerfile | Base → python:3.14-slim; add uv install layer; replace pip with uv |
backend/pyproject.toml | requires-python = ">=3.14"; remove gunicorn>=22.0,<27.0; add granian>=2.7.7,<3.0 |
backend/requirements.txt | Delete (pyproject.toml is source of truth) |
backend/.python-version | 3.14 |
.github/workflows/ci-be.yml | Replace pip with uv for backend installs; Python 3.14 |
.github/workflows/ci-e2e.yml | Replace pip with uv; Python 3.14 |
.github/dependabot.yml | package-ecosystem: "uv" |
README.md | Replace pip instructions with uv |
docs/LOCAL_DEV.md | Replace pip with uv |
monorepo.md | Replace pip references with uv |
docs/specs/F54-ci-security-scanning.md | Add uv-audit as replacement for pip-audit |
docs/ADR/008-messaging-app-and-sse.md | Note: ADR’s “no ASGI in v1” is superseded by F56 |
.pre-commit-config.yaml | Add uv lock --check |
11. Implementation Audit
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Python 3.14 base image (python:3.14-slim) in Dockerfile | ✅ match | backend/Dockerfile |
| 2 | uv installed + used for dependency management (uv sync --frozen) | ✅ match | backend/Dockerfile; .github/workflows/ci-be.yml |
| 3 | Granian ASGI server replacing Gunicorn (granian --interface asgi) | ✅ match | backend/entrypoint.sh; backend/pyproject.toml |
| 4 | pyproject.toml as source of truth; requirements.txt deleted | ✅ match | backend/pyproject.toml; backend/requirements.txt removed |
| 5 | CI/CD workflows (ci-be.yml, e2e workflow) use uv + Python 3.14 | ✅ match | .github/workflows/ci-be.yml; .github/workflows/ci-e2e.yml (not e2e.yml — no such file) |
| 6 | deploy/AGENTS.md updated (uv/Granian/ASGI references) | ✅ match | deploy/AGENTS.md |
| 7 | Dependabot configured for uv ecosystem | ✅ match | .github/dependabot.yml |
12. Open Questions / Decisions
All decisions resolved:
-
Python 3.14 mode: Default (GIL-enabled). Free-threaded (
3.14t) deferred — experimental, breaks C extensions, no CPU-bound parallel workloads in v1. Revisit after Django officially supports free-threaded mode. -
ASGI or WSGI:
--interface asgi. Django’s ASGI handler wraps sync views transparently. Existingconfig/asgi.pyis production-ready. Unlocks async views (SSE, streaming, WebSocket-prep) without future migration. -
Dependency manifest:
pyproject.tomlonly;requirements.txtdeleted.uv export --format requirements.txtavailable if a static list is needed for Docker multi-stage builds.
13. Bug / Change Log
2026-07-02 — Fix stale granian version and CI workflow filename (batch-2 spec audit)
Type: Documentation Fix
Summary:
- §10 table: corrected
granian>=1.6,<2.0→granian>=2.7.7,<3.0to matchbackend/pyproject.tomlline 20. (Granian was bumped to 2.x after the original F56 PR.) - §10 table and §11 row 5: corrected
.github/workflows/e2e.yml→.github/workflows/ci-e2e.yml. No file namede2e.ymlexists in.github/workflows/; the actual file isci-e2e.yml.
2026-07-02 — Verified: requirements.txt references are accurate historical context (batch 3)
Type: Documentation
Summary:
- Audited all
requirements.txtoccurrences: §3 (migration description), §8 Current Behavior (pre-F56 state), §10 (deletion listed as the action taken), §11 row 4 (“removed”), §12 decision 3 (“deleted”). All are correct — they describe the pre-F56 state or record that the file was deleted. - No stale paths found; no changes to spec content.
2026-06-15 — Fix ASGI lifespan ValueError on boot (Sentry LUMI-BE-3)
Type: Bug Fix
Summary:
- Granian emits the ASGI
lifespanscope on startup/shutdown; Django’sASGIHandlerraisedValueError: Django can only handle ASGI/HTTP connections, not lifespan.on every boot (80 events, unhandled). - Added
backend/config/lifespan.py(LifespanMiddleware) that answerslifespan.startup/lifespan.shutdownand forwardshttp/websocketscopes untouched. - Wired the wrapper into
backend/config/asgi.py:application = LifespanMiddleware(get_asgi_application()). - Added
backend/tests/test_asgi_lifespan.py— 5 cases, 100% coverage ofconfig/lifespan.py. See §7 cases 9–11.
2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)
Type: Documentation Change
Summary:
- §10 Decisions (non-standard section) merged into §12 Open Questions / Decisions as resolved decisions.
- §12 Implementation Audit converted from one-line narrative to canonical 4-column table (7 rows, all ✅).
- Sections renumbered: §11 Diags → §10, §12 Audit → §11, §13 OQ → §12 (with §10 Decisions content), §14 BL → §13.
Reason:
- Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.
Impact:
- Spec authors and future implementation agents.
n/a — first version.
2026-07-15 — Contract Graph migration slice
The active platform boundary is now represented by contracts/features/F56.cue and six
hermetic repository proofs in scripts/tests/test_f56_python314_uv_asgi.py. During migration,
the manifest’s stale >=3.12 floor was corrected to match the executable Docker, CI, and
.python-version selectors; uv.lock was refreshed accordingly. This source-level evidence
does not claim a production image build, hosted CI run, health response, load latency, SSE
rolling-deploy recovery, Redis, Sentry, or production evidence. Those remain blocking gaps with
an owner, reproduction command, and next action in the contract.