Appendix M: The Canonical Technology Stack
This appendix is the curated three-tier list referenced from Section 4.5 of the main methodology document. Every S4U project's ADR-0001 references this list as its starting point and either accepts the canonical defaults or amends them with explicit rationale.
The stack is curated from the union of three S4U projects in production: Trust Relay (workflow + RAG, FastAPI/Next.js), Zol-RAG (voice + RAG, FastAPI/React+Vite + LiveKit — frontend deviates from baseline, see below), and Ratiba.chat (conversational SMB management, FastAPI/Next.js + WhatsApp + M-Pesa). Each entry below carries an "inspired-from" reference so the lineage is auditable.
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.
Backend Runtime + Framework
Mandatory
| Item | Inspired-from | Notes |
|---|
| Python 3.13+ | Ratiba ADR-0001 (amended 2026-04-25) | Pin in pyproject.toml::requires-python. Re-evaluate annually. Trust Relay + Zol-RAG should bump to 3.13 at next opportunity. |
| FastAPI | Trust Relay, Zol-RAG, Ratiba (all three) | Async-first; matches the lifespan + dependency-injection patterns shared across all S4U projects. |
Pydantic v2 with model_config = ConfigDict(extra="forbid") everywhere | Trust Relay, Zol-RAG, Ratiba | The extra="forbid" discipline catches schema drift at runtime. Used at every API boundary, every config object, every ORM-adjacent DTO. |
Type hints + pyright for static type checking | Trust Relay (full coverage), Ratiba (full coverage post-M3) | Pyright via the pyright-lsp plugin in Claude Code; pyright command-line in CI. Type errors are commit-blocking. |
Default
| Item | Inspired-from | Notes |
|---|
uvicorn for ASGI server | All three | Standard FastAPI runtime; no compelling alternative. |
python-dotenv via pydantic-settings | All three | Settings 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
Mandatory
| Item | Inspired-from | Notes |
|---|
| PostgreSQL 16+ | Ratiba ADR-0001, Trust Relay | The single relational store across all S4U projects. Schema-per-tenant (Ratiba) or single-schema (Trust Relay) depending on multi-tenancy needs. |
| Alembic for migrations | All three | Per-tenant invocation pattern in Ratiba (per-tenant ADR-0002); single-database invocation in Trust Relay + Zol-RAG. |
testcontainers for integration tests against real Postgres | All three | Required for the no-mocking-by-default rule (see §7) to be enforceable. In-memory DB substitution is forbidden (below). |
Default
| Item | Inspired-from | Notes |
|---|
asyncpg for application Postgres queries | Trust Relay, Ratiba | Fastest Python Postgres driver; native to the async stack. |
SQLAlchemy 2.0 (async) for ORM | Trust Relay, Ratiba | Mature schema-per-tenant story (SET search_path per session). |
psycopg 3 only when a library forces it | Ratiba ADR-0001 amendment | Specifically for langgraph-checkpoint-postgres (see Ratiba M3 spike). Two-driver wart documented in ADR-0001 Consequences. |
Redis 7 for cache + ephemeral state | All three | Session state, rate limiting, webhook dedup, FSM hot state. |
MinIO for object storage in dev | Trust Relay, Ratiba | S3-compatible; dev-time substitute for AWS S3. |
Forbidden
- In-memory databases (SQLite, etc.) as test fixtures for code that targets Postgres in production. The behaviour drift between SQLite and Postgres has bitten Trust Relay (mock/prod divergence in 2026-Q1). Always testcontainers.
- The
::jsonb cast syntax with asyncpg. Use CAST(:param AS jsonb) (already in the global CLAUDE.md). Asyncpg's parameter parser breaks on ::.
- Python-side
default=... on SQLAlchemy ORM columns when the migration sets server_default. SQLAlchemy's compare_metadata() (used by Alembic autogenerate diffs in test_models_match_migration_metadata-style 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("..."). Codified after Ratiba M6 T11 — added in v2.1.2 (2026-04-30).
- 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 instead of pool.release(conn) — the pool detects the dead conn and re-creates it. Codified after Ratiba M9 T1 — added in v2.1.3 (2026-05-05). Pin in production-LISTEN consumers AND in tests using pool.add_listener(...) (test-pollution under shared LISTEN connection has the same root cause; per-test fresh asyncpg connection avoids it).
Testing
Mandatory
| Item | Inspired-from | Notes |
|---|
pytest with asyncio_mode = "auto" | All three | Standard async test config. |
testcontainers for any external service (Postgres, Keycloak, Redis, MinIO, etc.) | All three | The mechanism that makes "no mocking by default" enforceable. |
| No-mocking-by-default rule (§7) | All three | 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 | Inspired-from | Notes |
|---|
respx for HTTP mocking when an external API is being mocked-with-approval | Ratiba M4 (WhatsApp Sender), Zol-RAG (Deepgram + ElevenLabs) | Mocks raw httpx; SDK transport mocking is more brittle. |
freezegun for time-travel tests | Ratiba (M3 lifecycle, M4 idempotency TTL) | Required for time.sleep() ban enforcement — fast tests of timing-dependent code. |
pytest-xdist parallelization when full-suite wall-clock exceeds ~10 min | Ratiba M7+ | testcontainer-spinup amortizes well across parallel workers; cuts ~18min suite to ~5min. Codified in v2.1.3 (2026-05-05) after Ratiba M7+M9 plateau at 15-18min. Mandatory tier when wall-clock crosses 10min. |
Forbidden
time.sleep() in tests. Mandatory replacement: asyncio.sleep() (when you genuinely need a yield) or freezegun (when you need time travel). Already in the global CLAUDE.md.
unittest.mock of internal classes. Mocks are for process boundaries (HTTP APIs, external services with approved mocks); never for first-party classes. The right test 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 (v2.1.3). pyright sees await_args as Optional[Call] regardless of the assert_awaited_once contract. Pin the explicit narrowing assert.
Frontend
Mandatory
| Item | Inspired-from | Notes |
|---|
| Next.js 14+ App Router | Trust Relay, Ratiba (zol-rag deviates: React+Vite, pre-dates the baseline; deviation ADR required at project level — the three-copies-all-false drift this row carried is assessment finding CE-5) | The S4U frontend baseline. App Router (not Pages Router) is the current standard. |
| TypeScript with strict mode | All three | No .js files in new frontend code. tsconfig.json::strict: true. |
| Tailwind CSS v4 (CSS-first) | Ratiba M9 | v4 forced by shadcn 4.x's @theme + oklch() syntax. v3 still works for legacy projects but new scaffolds get v4. Codified in v2.1.3 (2026-05-05). |
shadcn/ui 4+ added per-component via npx shadcn@latest add <component> | All three | NOT a direct dependency. Components are copied into the project so they can be customised. shadcn 4.x emits Tailwind v4 syntax — pairing forces the v4 entry above. |
| Sonner for toast notifications | All three | The mandatory replacement for alert() / native dialogs (forbidden tier). |
Default
| Item | Inspired-from | Notes |
|---|
next/font for font loading | Trust Relay, Ratiba | Self-hosted; no Google Fonts CDN dependency. |
| Skeleton loaders (not spinners) for content loading | All three | Per S4U UI/UX rules. |
next-auth@5.x (NextAuth v5) with Keycloak provider for SSO | Ratiba M9 | v5 server-component auth() returns the session synchronously in Server Components; account.access_token captured into session.accessToken via JWT/session callbacks. Codified in v2.1.3 (2026-05-05). |
vitest + @testing-library/react for component tests | Ratiba M9 | Vitest 4+ requires @testing-library/jest-dom/vitest subpath import (not the root) for matcher augmentation; needs vitest.setup.ts in setupFiles config. |
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
Mandatory
| Item | Inspired-from | Notes |
|---|
structlog for all application logging | Trust Relay, Zol-RAG, Ratiba | Stdlib logging direct usage is forbidden in application code (a process-level handler that captures structlog and routes to stdlib for transport is fine — that's plumbing, not application code). Mandate enables the evidence-over-claims rule from §2.3 (structured logs are mechanically queryable; unstructured print() output is not). |
structlog.testing.capture_logs() for log assertions in tests | Ratiba (codified during M5 T1 cost-ceiling tests) | pytest's caplog only captures stdlib logging; structlog isn't routed through stdlib by default in S4U projects, so caplog silently fails to capture structlog events. Always use capture_logs(). |
Default
| Item | Inspired-from | Notes |
|---|
| Self-hosted observability (no SaaS by default) | Ratiba ADR-0001 (no-cloud-dependencies bias) | 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
Mandatory
| Item | Inspired-from | Notes |
|---|
| Keycloak 24+ for production authentication | Trust Relay, Ratiba | Self-hosted; no per-MAU SaaS cost. Tenant realms (Ratiba) or single realm (Trust Relay) per multi-tenancy shape. |
| OIDC for dashboard auth flows | All three | Keycloak as the OIDC provider. |
Default
| Item | Inspired-from | Notes |
|---|
python-keycloak library for backend Keycloak admin API | Trust Relay, Ratiba | The maintained Python client. Note Ratiba M3's user_realm_name="master" decoupling pattern (Keycloak admin user lives in master realm; tenant realms are addressed by URL path). |
Forbidden
- Hardcoded admin keys committed to the repo. PoC artifacts allowed (e.g., Ratiba's
ADMIN_API_KEY for /api/v1/admin/*) but must be replaced by Keycloak master-realm + role-based access by milestone X (specified per project). Never long-lived secrets in version control.
Infrastructure + Containers
Mandatory
| Item | Inspired-from | Notes |
|---|
| Docker Compose for dev + small-VPS prod | All three | Production path to Kubernetes if scale demands it; not on any current S4U roadmap. |
| Per-project port assignment table in CLAUDE.md | All three | All three projects can run concurrently with no host-port conflicts. The table is shared in §4.3 / project CLAUDE.md. |
Default
| Item | Inspired-from | Notes |
|---|
pyproject.toml (PEP 621) for Python dependencies | All three | Not requirements.txt. |
pip install -e ".[dev]" for editable dev installs | All three | 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."
Default (when in scope)
| Item | Inspired-from | Notes |
|---|
Deepgram Nova-3 for STT | Zol-RAG, Ratiba | Multilingual; same key management across S4U. |
ElevenLabs Multilingual v2 for TTS | Zol-RAG, Ratiba | Reuse playbook from sister projects. |
LiveKit (SIP + rooms) for telephony | Zol-RAG, Ratiba | SIP bridge for phone-number ingress; rooms for the agent worker pattern. |
langgraph + langgraph-checkpoint-postgres for FSM orchestration | Ratiba M5 | Carries the MIGRATIONS workaround for CREATE INDEX CONCURRENTLY (Ratiba upstream issue #7630 at langchain-ai/langgraph). |
Library Currency Policy
Per Ratiba ADR-0001 amendment (2026-04-25), the canonical stack is not a frozen list. 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
|---|---|
| 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
|---|---|---|
| `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 three production S4U projects. Items move between tiers by amendment, not by per-PR judgment. The full list above is the source of truth; project ADR-0001s reference back rather than re-deriving.