Skip to main content

Appendix M: The Canonical Technology Stack

The curated three-tier stack referenced from Section 4.5. Every S4U project's ADR-0001 starts from this list and either accepts the defaults or amends them with explicit rationale.

In one line: mandatory items need a new ADR to deviate; defaults need a one-line ADR-0001 entry; forbidden items have no deviation path.

The stack is curated from the union of S4U projects in production — backend RAG/workflow services, voice/conversational services, and SMB-management services, all FastAPI-based. The lineage is auditable via each project's ADR-0001.

Tier definitions

  • Mandatory. Always use this. Deviation requires a new ADR in the project's docs/adr/ justifying why this project departs from canon and what the alternative buys.
  • Default. Use this unless you have a project-specific reason to pick differently. Reasons go in the project's existing ADR-0001 as a "deviation rationale" entry. No new ADR needed.
  • Forbidden. Never use this regardless of project. No deviation path; using forbidden items breaks methodology compliance.

Promotion rules (per §4.5 rule 3): a library moves from default to mandatory after ≥2 projects ship with it for ≥3 months. A library moves to forbidden after ≥1 project hits a real failure caused by it. New additions land in default first.

Do this: when scaffolding a project, copy the mandatory + default rows into ADR-0001 and record any deviation inline.


Backend Runtime + Framework

In one line: async-first Python, Pydantic v2 with extra="forbid" everywhere, types enforced by pyright in CI.

Mandatory

ItemNotes
Python 3.13+Pin in pyproject.toml::requires-python. Re-evaluate annually.
FastAPIAsync-first; lifespan + dependency-injection patterns.
Pydantic v2 with model_config = ConfigDict(extra="forbid") everywhereextra="forbid" catches schema drift at runtime. Use at every API boundary, config object, and ORM-adjacent DTO.
Type hints + pyright for static type checkingPyright in CI; type errors are commit-blocking.

Default

ItemNotes
uvicorn for ASGI serverStandard FastAPI runtime; no compelling alternative.
python-dotenv via pydantic-settingsSettings class with env_file=".env"; production secrets injected via deploy infra, not .env.

Forbidden

  • Stdlib logging directly in application code. Mandatory replacement: structlog. See observability tier.
  • requests library for new HTTP code. Sync, not async-native; falls outside the FastAPI async story.

Data Layer

In one line: PostgreSQL is the only relational store; integration tests hit a real Postgres via testcontainers, never an in-memory substitute.

Mandatory

ItemNotes
PostgreSQL 16+The single relational store. Schema-per-tenant or single-schema depending on multi-tenancy needs.
Alembic for migrationsPer-tenant or single-database invocation per project shape.
testcontainers for integration tests against real PostgresMakes the no-mocking-by-default rule (§7) enforceable. In-memory DB substitution is forbidden (below).

Default

ItemNotes
asyncpg for application Postgres queriesFastest Python Postgres driver; native to the async stack.
SQLAlchemy 2.0 (async) for ORMMature schema-per-tenant story (SET search_path per session).
psycopg 3 only when a library forces itE.g. langgraph-checkpoint-postgres. Document the two-driver wart in ADR-0001 Consequences.
Redis 7 for cache + ephemeral stateSession state, rate limiting, webhook dedup, FSM hot state.
MinIO for object storage in devS3-compatible; dev-time substitute for AWS S3.

Forbidden

  • In-memory databases (SQLite, etc.) as test fixtures for code that targets Postgres in production. Behaviour drift between SQLite and Postgres produces mock/prod divergence. Always testcontainers.
  • The ::jsonb cast syntax with asyncpg. Use CAST(:param AS jsonb). Asyncpg's parameter parser breaks on ::.
  • Python-side default=... on SQLAlchemy ORM columns when the migration sets server_default. compare_metadata() (used by Alembic autogenerate-diff tests) only compares DB-side defaults; Python-side default=False reads as drift against migration server_default=text("FALSE"). ORM columns with non-NULL defaults must mirror the migration verbatim with server_default=text("...").
  • Releasing an asyncpg LISTEN connection back to the shared pool via pool.release(conn). Listener-state connections deadlock pool.close() on shutdown — the listener task holds a reference; the pool can't drain. Use await conn.close() directly in the listener task's finally; the pool detects the dead conn and re-creates it. Applies to production-LISTEN consumers and to tests using pool.add_listener(...) — a per-test fresh asyncpg connection avoids the same test-pollution root cause.

Testing

In one line: real services via testcontainers by default; any external-API mock needs an explicit MOCK APPROVED comment; first-party classes are never mocked.

Mandatory

ItemNotes
pytest with asyncio_mode = "auto"Standard async test config.
testcontainers for any external service (Postgres, Keycloak, Redis, MinIO, etc.)Makes "no mocking by default" enforceable.
No-mocking-by-default rule (§7)Mocking external APIs requires an explicit # MOCK APPROVED: comment with reason + approver + alternative-real-service path. Internal class mocking is forbidden (next tier).

Default

ItemNotes
respx for HTTP mocking when an external API is being mocked-with-approvalMocks raw httpx; SDK transport mocking is more brittle.
freezegun for time-travel testsRequired for time.sleep() ban enforcement — fast tests of timing-dependent code.
pytest-xdist parallelization when full-suite wall-clock exceeds ~10 mintestcontainer-spinup amortizes well across parallel workers. Mandatory tier when wall-clock crosses 10min.

Forbidden

  • time.sleep() in tests. Replace with asyncio.sleep() (genuine yield) or freezegun (time travel).
  • unittest.mock of internal classes. Mocks are for process boundaries (HTTP APIs, approved external services); never for first-party classes. The right seam for first-party code is fixture injection.
  • mock.assert_awaited_once() followed by direct .await_args.kwargs access without an explicit assert mock.await_args is not None between them. pyright sees await_args as Optional[Call] regardless of the assert_awaited_once contract. Pin the explicit narrowing assert.

Frontend

In one line: Next.js App Router + strict TypeScript + Tailwind v4 + shadcn/ui; native dialogs are forbidden — use inline UI and Sonner toasts.

Mandatory

ItemNotes
Next.js 14+ App RouterThe S4U frontend baseline. App Router (not Pages Router) is current. Projects predating this baseline (e.g. React + Vite) carry a project-level deviation ADR.
TypeScript with strict modeNo .js files in new frontend code. tsconfig.json::strict: true.
Tailwind CSS v4 (CSS-first)v4 forced by shadcn 4.x's @theme + oklch() syntax. v3 still works for legacy projects; new scaffolds get v4.
shadcn/ui 4+ added per-component via npx shadcn@latest add <component>NOT a direct dependency — components are copied in so they can be customised. shadcn 4.x emits Tailwind v4 syntax.
Sonner for toast notificationsMandatory replacement for alert() / native dialogs (forbidden tier).

Default

ItemNotes
next/font for font loadingSelf-hosted; no Google Fonts CDN dependency.
Skeleton loaders (not spinners) for content loadingPer S4U UI/UX rules.
next-auth@5.x (NextAuth v5) with Keycloak provider for SSOv5 auth() returns the session synchronously in Server Components; capture account.access_token into session.accessToken via JWT/session callbacks.
vitest + @testing-library/react for component testsVitest 4+ needs the @testing-library/jest-dom/vitest subpath import (not the root) for matcher augmentation, plus vitest.setup.ts in setupFiles.

Forbidden

  • alert(), confirm(), native browser dialogs for any user-facing flow. Mandatory replacement: inline confirmation UI + Sonner toasts (already in S4U UI/UX rules).
  • Modal dialogs for simple confirmations. Use inline UI or Sheet (per §UI/UX rules).
  • Pages Router for new Next.js projects. App Router is canonical.

Observability + Logging

In one line: all application logging goes through structlog; assert on logs with capture_logs(), never pytest's caplog.

Mandatory

ItemNotes
structlog for all application loggingDirect stdlib logging is forbidden in application code (a process-level handler that captures structlog and routes to stdlib for transport is fine — that's plumbing). Enables the evidence-over-claims rule (§2.3): structured logs are mechanically queryable; print() output is not.
structlog.testing.capture_logs() for log assertions in testspytest's caplog only captures stdlib logging; structlog isn't routed through stdlib by default, so caplog silently misses structlog events. Always use capture_logs().

Default

ItemNotes
Self-hosted observability (no SaaS by default)Project-specific deviation acceptable; document in ADR-0001.

Forbidden

  • Stdlib logging directly in application code. See mandatory tier.
  • Ad-hoc print() for logging. Always structlog.

Authentication + Identity

In one line: self-hosted Keycloak + OIDC for all auth; no long-lived secrets in version control.

Mandatory

ItemNotes
Keycloak 24+ for production authenticationSelf-hosted; no per-MAU SaaS cost. Tenant realms or single realm per multi-tenancy shape.
OIDC for dashboard auth flowsKeycloak as the OIDC provider.

Default

ItemNotes
python-keycloak library for backend Keycloak admin APIThe maintained Python client. Decouple the admin user with user_realm_name="master" (admin lives in master realm; tenant realms addressed by URL path).

Forbidden

  • Hardcoded admin keys committed to the repo. A short-lived PoC ADMIN_API_KEY is tolerable but must be replaced by Keycloak master-realm + role-based access by a named milestone. Never long-lived secrets in version control.

Infrastructure + Containers

In one line: Docker Compose for dev and small-VPS prod; every project declares a port-assignment table so they run concurrently without conflict.

Mandatory

ItemNotes
Docker Compose for dev + small-VPS prodKubernetes only if scale demands it.
Per-project port assignment table in CLAUDE.mdLets projects run concurrently with no host-port conflicts. The table lives in §4.3 / project CLAUDE.md.

Default

ItemNotes
pyproject.toml (PEP 621) for Python dependenciesNot requirements.txt.
pip install -e ".[dev]" for editable dev installsStandard pattern.

Forbidden

  • Manual non-versioned dependency installs in production. Pin everything; reproducible builds only.

Voice + Conversational (project-specific tiers)

These items are mandatory only for projects with voice or conversational AI scope. Stack-wide they are "default per scope."

In one line: voice projects standardize on Deepgram STT + ElevenLabs TTS + LiveKit telephony + langgraph FSM orchestration.

Default (when in scope)

ItemNotes
Deepgram Nova-3 for STTMultilingual; shared key management.
ElevenLabs Multilingual v2 for TTSReusable playbook across projects.
LiveKit (SIP + rooms) for telephonySIP bridge for phone-number ingress; rooms for the agent worker pattern.
langgraph + langgraph-checkpoint-postgres for FSM orchestrationCarries a MIGRATIONS workaround for CREATE INDEX CONCURRENTLY (upstream langgraph issue).

Library Currency Policy

The canonical stack is not a frozen list.

In one line: pin to latest stable at lock-in, auto-apply patches, review minors monthly and majors quarterly.

Currency policy:

  1. At lock-in (initial pinning): every dependency is pinned to its latest stable release at the moment of pinning. No "match what sister-project N uses" — the PoC is the moment to start at the front.
  2. Patch releases (x.y.Z): auto-applied via Renovate / Dependabot.
  3. Minor releases (x.Y.0): reviewed monthly. Adopt unless something breaks.
  4. Major releases (X.0.0): reviewed quarterly. ADR-worthy for any major bump that changes a load-bearing contract.
  5. Deprecation tracking: subscribe to release notes for the load-bearing libraries (FastAPI, Pydantic, SQLAlchemy, structlog, Next.js, shadcn/ui).

Promotion / demotion of stack items between tiers happens by methodology amendment (this appendix is amended; methodology version bumps to vN.M+1).


Deviation Templates

Template 1: Mandatory deviation (new ADR)

Create docs/adr/ADR-NNNN-{deviation-name}.md:

# ADR-NNNN: Deviate from Canonical Stack — {item}

**Status:** Accepted
**Date:** YYYY-MM-DD

## Context

The canonical S4U methodology stack mandates {mandatory item} (see appendix-m). This project departs from the mandate because {project-specific reason that genuinely doesn't apply to other S4U projects}.

## Decision

Use {alternative} instead of {mandatory item} for {scope}.

## Consequences

**Positive.** {What the alternative buys}.
**Negative.** {What the deviation costs vs canonical: contributor onboarding cost, sister-project skill non-transfer, plumbing differences, etc.}.

## Alternatives Considered

| Alternative | Rejected because |
|---|---|
| Use the mandatory item | {Why it doesn't fit this project} |
| {Other alternative} | {Why} |

Template 2: Default deviation (ADR-0001 entry)

Add a section to the project's docs/adr/ADR-0001-tech-stack.md:

## Deviations from Canonical Stack

| Default item | Replaced with | Rationale |
|---|---|---|
| `httpx` | `aiohttp` | {Project-specific reason — e.g., aiohttp's WebSocket support is needed and httpx doesn't have it} |

No new ADR; the existing ADR-0001 carries the rationale.


Summary

The canonical stack is the methodology's answer to "what should I build with?" — three tiers, each with proportional deviation cost, curated from the union of production S4U projects. Items move between tiers by amendment, not by per-PR judgment. The list above is the source of truth; project ADR-0001s reference back rather than re-deriving.