🚧 Source and repository proof covers the platform boundary; live, deployed, and production evidence remains open.

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.
  • 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.py to be the single entrypoint so that I can write async views without dual-maintenance.
    • Given the production server starts with --interface asgi, when config.asgi.application is loaded, then all existing sync views work unchanged.

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-version and requires-python.
  • Out: Rewriting sync views as async — all existing views work under ASGI (Django handles sync→async dispatch). Migrating from requirements.txt to pyproject.toml only (already dual-managed; this spec locks pyproject.toml as the source of truth and removes requirements.txt duplication). 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

#CaseTypeWhere
1uv sync resolves and installs all depsinfraLocal dev
2uv pip install works for ad-hoc packagesinfraCI
3Django ASGI application loads under Granianinfragranian --interface asgi config.asgi:application
4All existing pytest suite passes under Granian ASGIbackendcd backend && python -m pytest
5Health endpoint returns 200e2ePOST /api/health/
6SSE streams survive under Granian ASGIe2eManual: stream connects, events flow, reconnect on restart
7Docker build succeeds with Python 3.14 and uvinfradocker build .
8CI workflow uses uv not pipinfraCI passes
9ASGI lifespan scope is answered, not crashed into Djangobackendbackend/tests/test_asgi_lifespan.py (startup/shutdown complete; lifespan never reaches Django)
10http / websocket scopes pass through the lifespan shim unchangedbackendbackend/tests/test_asgi_lifespan.py
11config.asgi.application is wrapped in LifespanMiddlewarebackendbackend/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" in pyproject.toml and uv.lock

Package management

  • pip used 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.txt uses 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.sh launches gunicorn

ASGI

  • config/asgi.py exists (standard get_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 sync fails 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 wsgi for isolation.
  • ASGI lifespan ValueError on boot: Granian emits the lifespan scope, which Django’s ASGIHandler rejects (ValueError: Django can only handle ASGI/HTTP connections, not lifespan.) — reported as an unhandled exception every boot (Sentry LUMI-BE-3). Fixed by config/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.sh to gunicorn WSGI, revert Dockerfile to python: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=1 to 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

DocumentChange (shipped)
docs/specs/F55-granian.mdMark as superseded by F56
deploy/AGENTS.mdReplace all pip/gunicorn/WSGI references with uv/Granian/ASGI
deploy/DIAGRAMS.mdUpdate architecture labels (ASGI, uv)
DIAGRAMS.mdUpdate backend subsystem label
backend/entrypoint.shReplace gunicorn with granian --interface asgi config.asgi:application
backend/DockerfileBase → python:3.14-slim; add uv install layer; replace pip with uv
backend/pyproject.tomlrequires-python = ">=3.14"; remove gunicorn>=22.0,<27.0; add granian>=2.7.7,<3.0
backend/requirements.txtDelete (pyproject.toml is source of truth)
backend/.python-version3.14
.github/workflows/ci-be.ymlReplace pip with uv for backend installs; Python 3.14
.github/workflows/ci-e2e.ymlReplace pip with uv; Python 3.14
.github/dependabot.ymlpackage-ecosystem: "uv"
README.mdReplace pip instructions with uv
docs/LOCAL_DEV.mdReplace pip with uv
monorepo.mdReplace pip references with uv
docs/specs/F54-ci-security-scanning.mdAdd uv-audit as replacement for pip-audit
docs/ADR/008-messaging-app-and-sse.mdNote: ADR’s “no ASGI in v1” is superseded by F56
.pre-commit-config.yamlAdd uv lock --check

11. Implementation Audit

#ClaimStatusEvidence
1Python 3.14 base image (python:3.14-slim) in Dockerfile✅ matchbackend/Dockerfile
2uv installed + used for dependency management (uv sync --frozen)✅ matchbackend/Dockerfile; .github/workflows/ci-be.yml
3Granian ASGI server replacing Gunicorn (granian --interface asgi)✅ matchbackend/entrypoint.sh; backend/pyproject.toml
4pyproject.toml as source of truth; requirements.txt deleted✅ matchbackend/pyproject.toml; backend/requirements.txt removed
5CI/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)
6deploy/AGENTS.md updated (uv/Granian/ASGI references)✅ matchdeploy/AGENTS.md
7Dependabot configured for uv ecosystem✅ match.github/dependabot.yml

12. Open Questions / Decisions

All decisions resolved:

  1. 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.

  2. ASGI or WSGI: --interface asgi. Django’s ASGI handler wraps sync views transparently. Existing config/asgi.py is production-ready. Unlocks async views (SSE, streaming, WebSocket-prep) without future migration.

  3. Dependency manifest: pyproject.toml only; requirements.txt deleted. uv export --format requirements.txt available 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.0 to match backend/pyproject.toml line 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 named e2e.yml exists in .github/workflows/; the actual file is ci-e2e.yml.

2026-07-02 — Verified: requirements.txt references are accurate historical context (batch 3)

Type: Documentation

Summary:

  • Audited all requirements.txt occurrences: §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 lifespan scope on startup/shutdown; Django’s ASGIHandler raised ValueError: Django can only handle ASGI/HTTP connections, not lifespan. on every boot (80 events, unhandled).
  • Added backend/config/lifespan.py (LifespanMiddleware) that answers lifespan.startup/lifespan.shutdown and forwards http/websocket scopes 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 of config/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.