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
| Item | Notes |
|---|---|
| Python 3.13+ | Pin in pyproject.toml::requires-python. Re-evaluate annually. |
| FastAPI | Async-first; lifespan + dependency-injection patterns. |
Pydantic v2 with model_config = ConfigDict(extra="forbid") everywhere | extra="forbid" catches schema drift at runtime. Use at every API boundary, config object, and ORM-adjacent DTO. |
Type hints + pyright for static type checking | Pyright in CI; type errors are commit-blocking. |
Default
| Item | Notes |
|---|---|
uvicorn for ASGI server | Standard FastAPI runtime; no compelling alternative. |
python-dotenv via pydantic-settings | Settings class with env_file=".env"; production secrets injected via deploy infra, not .env. |
Forbidden
- Stdlib
loggingdirectly in application code. Mandatory replacement:structlog. See observability tier. requestslibrary 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
| Item | Notes |
|---|---|
| PostgreSQL 16+ | The single relational store. Schema-per-tenant or single-schema depending on multi-tenancy needs. |
| Alembic for migrations | Per-tenant or single-database invocation per project shape. |
testcontainers for integration tests against real Postgres | Makes the no-mocking-by-default rule (§7) enforceable. In-memory DB substitution is forbidden (below). |
Default
| Item | Notes |
|---|---|
asyncpg for application Postgres queries | Fastest Python Postgres driver; native to the async stack. |
SQLAlchemy 2.0 (async) for ORM | Mature schema-per-tenant story (SET search_path per session). |
psycopg 3 only when a library forces it | E.g. langgraph-checkpoint-postgres. Document the two-driver wart in ADR-0001 Consequences. |
Redis 7 for cache + ephemeral state | Session state, rate limiting, webhook dedup, FSM hot state. |
MinIO for object storage in dev | S3-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
::jsonbcast syntax withasyncpg. UseCAST(:param AS jsonb). Asyncpg's parameter parser breaks on::. - Python-side
default=...on SQLAlchemy ORM columns when the migration setsserver_default.compare_metadata()(used by Alembic autogenerate-diff tests) only compares DB-side defaults; Python-sidedefault=Falsereads as drift against migrationserver_default=text("FALSE"). ORM columns with non-NULL defaults must mirror the migration verbatim withserver_default=text("..."). - Releasing an asyncpg LISTEN connection back to the shared pool via
pool.release(conn). Listener-state connections deadlockpool.close()on shutdown — the listener task holds a reference; the pool can't drain. Useawait conn.close()directly in the listener task'sfinally; the pool detects the dead conn and re-creates it. Applies to production-LISTEN consumers and to tests usingpool.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
| Item | Notes |
|---|---|
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
| Item | Notes |
|---|---|
respx for HTTP mocking when an external API is being mocked-with-approval | Mocks raw httpx; SDK transport mocking is more brittle. |
freezegun for time-travel tests | Required for time.sleep() ban enforcement — fast tests of timing-dependent code. |
pytest-xdist parallelization when full-suite wall-clock exceeds ~10 min | testcontainer-spinup amortizes well across parallel workers. Mandatory tier when wall-clock crosses 10min. |
Forbidden
time.sleep()in tests. Replace withasyncio.sleep()(genuine yield) orfreezegun(time travel).unittest.mockof 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.kwargsaccess without an explicitassert mock.await_args is not Nonebetween them. pyright seesawait_argsasOptional[Call]regardless of theassert_awaited_oncecontract. 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
| Item | Notes |
|---|---|
| Next.js 14+ App Router | The 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 mode | No .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 notifications | Mandatory replacement for alert() / native dialogs (forbidden tier). |
Default
| Item | Notes |
|---|---|
next/font for font loading | Self-hosted; no Google Fonts CDN dependency. |
| Skeleton loaders (not spinners) for content loading | Per S4U UI/UX rules. |
next-auth@5.x (NextAuth v5) with Keycloak provider for SSO | v5 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 tests | Vitest 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
| Item | Notes |
|---|---|
structlog for all application logging | Direct 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 tests | pytest'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
| Item | Notes |
|---|---|
| Self-hosted observability (no SaaS by default) | Project-specific deviation acceptable; document in ADR-0001. |
Forbidden
- Stdlib
loggingdirectly in application code. See mandatory tier. - Ad-hoc
print()for logging. Alwaysstructlog.
Authentication + Identity
In one line: self-hosted Keycloak + OIDC for all auth; no long-lived secrets in version control.
Mandatory
| Item | Notes |
|---|---|
| Keycloak 24+ for production authentication | Self-hosted; no per-MAU SaaS cost. Tenant realms or single realm per multi-tenancy shape. |
| OIDC for dashboard auth flows | Keycloak as the OIDC provider. |
Default
| Item | Notes |
|---|---|
python-keycloak library for backend Keycloak admin API | The 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_KEYis tolerable but must be replaced by Keycloakmaster-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
| Item | Notes |
|---|---|
| Docker Compose for dev + small-VPS prod | Kubernetes only if scale demands it. |
| Per-project port assignment table in CLAUDE.md | Lets projects run concurrently with no host-port conflicts. The table lives in §4.3 / project CLAUDE.md. |
Default
| Item | Notes |
|---|---|
pyproject.toml (PEP 621) for Python dependencies | Not requirements.txt. |
pip install -e ".[dev]" for editable dev installs | Standard 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)
| Item | Notes |
|---|---|
Deepgram Nova-3 for STT | Multilingual; shared key management. |
ElevenLabs Multilingual v2 for TTS | Reusable playbook across projects. |
LiveKit (SIP + rooms) for telephony | SIP bridge for phone-number ingress; rooms for the agent worker pattern. |
langgraph + langgraph-checkpoint-postgres for FSM orchestration | Carries 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:
- 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.
- Patch releases (
x.y.Z): auto-applied via Renovate / Dependabot. - Minor releases (
x.Y.0): reviewed monthly. Adopt unless something breaks. - Major releases (
X.0.0): reviewed quarterly. ADR-worthy for any major bump that changes a load-bearing contract. - 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.