diff --git a/README.md b/README.md
index 05ec36e..b4b15b4 100755
--- a/README.md
+++ b/README.md
@@ -279,7 +279,7 @@ Domain-expert lenses `hyper` auto-engages when their triggers match the request
-🎯 Domain (7) - specialized skills for specific contexts
+🎯 Domain (8) - specialized skills for specific contexts
| Skill | Role |
|---|---|
@@ -290,6 +290,7 @@ Domain-expert lenses `hyper` auto-engages when their triggers match the request
| `security-review` | OWASP audits, vulnerability checklists |
| `design-patterns-skill` | Clean Code + Pragmatic Programmer patterns |
| `readme-writer` | Evidence-based README generation (this skill) |
+| `python-pro-coder` | 60 FastAPI/Pydantic v2/SQLAlchemy 2.0 rules - layering, schemas, async, dead deps |
diff --git a/skills/INDEX.md b/skills/INDEX.md
index 4da2b83..9b04f97 100644
--- a/skills/INDEX.md
+++ b/skills/INDEX.md
@@ -41,6 +41,7 @@ Categories:
| `design-patterns-skill` | Apply core programming principles and design patterns from Clean Code, The Pragmatic Programmer, Code Complete, Refactor |
| `designer` | |
| `marketing` | Use to do product marketing for any brand - position it, find the message, write the copy ("marketing words"), set brand |
+| `python-pro-coder` | Staff-level Python API engineering discipline for FastAPI + Pydantic v2 + SQLAlchemy 2.0 - 60 enforced rules across proj |
| `react-pro-coder` | Staff-level React and Next.js engineering discipline - 56 enforced rules across component design, hooks, state placement |
| `readme-writer` | Writes or rewrites project README files using repository evidence instead of generic filler. Use when creating a new REA |
| `reflect` | Review a product screen OR a feature (shipped or planned) AS a real target-customer persona - short, blunt, moody, marke |
diff --git a/skills/python-pro-coder/SKILL.md b/skills/python-pro-coder/SKILL.md
new file mode 100644
index 0000000..346341e
--- /dev/null
+++ b/skills/python-pro-coder/SKILL.md
@@ -0,0 +1,160 @@
+---
+name: python-pro-coder
+category: domain
+description: Staff-level Python API engineering discipline for FastAPI + Pydantic v2 + SQLAlchemy 2.0 - 60 enforced rules across project structure, Pydantic modelling, endpoint design, settings, dependency injection, async and performance, security, error handling, database access, testing, and production observability, plus a version-verified stack file that names the dead dependencies (python-jose, passlib, on_event) and their replacements. Use when writing, reviewing, refactoring, debugging, or auditing FastAPI or Pydantic code, when designing an API surface or its schemas, when choosing async versus sync, or when asked for Python API best practices or an audit.
+references:
+ - references/PROJECT-STRUCTURE.md
+ - references/PYDANTIC.md
+ - references/ENDPOINTS.md
+ - references/CONFIG-VALIDATION.md
+ - references/DEPENDENCIES.md
+ - references/ASYNC-PERFORMANCE.md
+ - references/SECURITY.md
+ - references/ERRORS.md
+ - references/DATABASE.md
+ - references/TESTING.md
+ - references/OBSERVABILITY.md
+ - references/REVIEW-CHECKLIST.md
+ - references/OUTPUT-CONTRACT.md
+ - references/TEMPLATES.md
+ - references/STACK-2026.md
+---
+
+# Python Pro Coder (SDE-3, FastAPI + Pydantic v2 + SQLAlchemy 2.0)
+
+## The Iron Law
+
+```
+NO DEPENDENCY RECOMMENDATION WITHOUT references/STACK-2026.md
+```
+
+The Python API ecosystem rots faster than memory updates. `python-jose` is abandoned with a live CVE,
+`passlib` breaks on Python 3.13, `@app.on_event` has been deprecated since FastAPI 0.93, and
+`AsyncClient(app=app)` is deprecated in httpx. Every one of those is still the first result an agent
+recalls. Read the stack file before naming a package or a version, and re-verify anything in it older
+than a quarter.
+
+Scope is HTTP API services. Data science, notebooks, CLI tools, and library packaging are out of
+scope: the typing, testing, and structure rules transfer, the FastAPI-specific ones do not.
+
+## Rationalization table
+
+| Excuse | Reality |
+|---|---|
+| "I know the FastAPI patterns" | The patterns you know are the 2023 ones. Three of them are deprecated. |
+| "It is one endpoint" | One endpoint without `response_model` is one endpoint leaking `hashed_password`. |
+| "The tutorial used `python-jose`" | The tutorial predates the abandonment. FastAPI's own docs moved to PyJWT. |
+| "Async is faster, make it all async" | AS-1: one blocking call inside `async def` stalls the whole loop. Consistency beats the label. |
+| "Mocking the database is faster" | TQ-1: mocks assert that your code called what you expected. Constraints and dialects are where the bugs are. |
+| "Pagination can come later" | AS-4: later is when a tenant reaches a million rows and takes the process down. |
+| "I will add the migration after" | DB-3: `create_all` in production has no downgrade and no history. |
+
+## Step 0: Environment gate (always first)
+
+```bash
+python -V # 3.10 is the floor for FastAPI 0.141
+uv pip list | grep -Ei 'fastapi|pydantic|sqlalchemy|httpx'
+ruff check . && ruff format --check .
+mypy --strict app/ # or pyright --strict
+pytest -q
+```
+
+Defaults when the project has not decided:
+
+| Concern | Default |
+|---|---|
+| Framework | FastAPI, one router per feature |
+| Validation | Pydantic v2 + pydantic-settings |
+| ORM | SQLAlchemy 2.0 async + Alembic + asyncpg |
+| Auth | PyJWT or joserfc + pwdlib, OAuth2 password bearer |
+| Tests | pytest + httpx `ASGITransport` + polyfactory + testcontainers |
+| Quality | Ruff (lint + format), mypy or pyright strict |
+| Packaging | uv |
+| Observability | structlog + OpenTelemetry + Sentry |
+
+## Step 1: Task classification (exactly one)
+
+| Class | Trigger words |
+|---|---|
+| New Feature | add endpoint, new service, create model, scaffold |
+| Refactor | restructure, split, extract, migrate, clean up |
+| Bug Fix | fix, 500, failing, regression, race, leak |
+| Performance | slow, N+1, timeout, throughput, blocking |
+| Review/Audit | review, audit, security check, schema check |
+| Documentation Only | document, explain, write up |
+
+Unclear class means stop and ask.
+
+## Step 2: Architecture-first order (never skip a layer)
+
+Boundaries, invariants, data ownership, transaction scope, public API surface (routes and schemas),
+module layout, files, functions, syntax. The transaction boundary is decided before the first query is
+written.
+
+Router to service to repository. Three layers, one direction.
+
+## Step 3: The rule set
+
+| Domain | Rules | Reference |
+|---|---|---|
+| Project structure | PS-1..PS-5 | `references/PROJECT-STRUCTURE.md` |
+| Pydantic v2 | PD-1..PD-8 | `references/PYDANTIC.md` |
+| Endpoint design | EP-1..EP-6 | `references/ENDPOINTS.md` |
+| Settings and validation | CF-1..CF-5 | `references/CONFIG-VALIDATION.md` |
+| Dependency injection | DI-1..DI-4 | `references/DEPENDENCIES.md` |
+| Async and performance | AS-1..AS-5 | `references/ASYNC-PERFORMANCE.md` |
+| Security | SE-1..SE-6 | `references/SECURITY.md` |
+| Error handling | ER-1..ER-4 | `references/ERRORS.md` |
+| Database and ORM | DB-1..DB-5 | `references/DATABASE.md` |
+| Testing and quality | TQ-1..TQ-5 | `references/TESTING.md` |
+| Production and observability | OB-1..OB-7 | `references/OBSERVABILITY.md` |
+
+Read the file for the domain being touched before writing code in it.
+
+## Step 4: Forbidden patterns (hard stops)
+
+| Pattern | Instead |
+|---|---|
+| `@app.on_event("startup")` | `lifespan` async context manager (PS-4) |
+| Pydantic v1 `class Config`, `@validator`, `.dict()`, `.parse_obj()` | `ConfigDict`, `@field_validator`, `.model_dump()`, `.model_validate()` (PD-1) |
+| One model for request, response, and ORM row | Separate Create / Update / Response / DB models (PD-2) |
+| `Query(default=...)` inside `Annotated` | Default with `=` on the parameter. The other form raises `AssertionError` at import (EP-3) |
+| Blocking calls in `async def` | `await`, or `run_in_threadpool` (AS-2) |
+| Unbounded list endpoints | Mandatory pagination with a bounded limit (AS-4) |
+| `Base.metadata.create_all()` in production | Alembic migration with a downgrade (DB-3) |
+| Returning an ORM object from a route | A response schema (SE-6) |
+| `python-jose`, `passlib` | PyJWT or joserfc, and pwdlib (SE-1, SE-2) |
+| `AsyncClient(app=app)` in tests | `AsyncClient(transport=ASGITransport(app=app))` (TQ-1) |
+| `datetime.utcnow()` | `datetime.now(UTC)`, deprecated since Python 3.12 |
+| Bare `except:` | `except Exception:` at minimum (DI-3) |
+| `os.getenv` scattered through the code | One `BaseSettings` object (CF-1) |
+
+## Step 5: Tests are part of the output
+
+Every endpoint owes a contract test: status code, response validated against the response model, and
+the exact key set. Plus the validation failure, the authorization failure, and the not-found case.
+Composes with `test-first` for ordering and `ship-gate` for evidence.
+
+## Step 6: Output contract and negative doubt
+
+Follow `references/OUTPUT-CONTRACT.md`. The negative-doubt routine runs before finalizing, and its hard
+stop applies: if correctness is still uncertain after the second pass, return the revised design and
+the missing inputs instead of code.
+
+## Review and audit mode
+
+`references/REVIEW-CHECKLIST.md` is the pass list, keyed to rule IDs, with the severity scale. Report
+findings as `path:line - RULE-ID - problem - fix`. Scaffolds in `references/TEMPLATES.md`.
+
+## Boundaries with other skills
+
+| Concern | Owner |
+|---|---|
+| Whether the feature should exist | `pm-gate` |
+| Vulnerability hunting across a codebase | `security-review`. SE-1..SE-6 are build-time rules, not an audit |
+| Algorithmic complexity of a hot path | `optimizer` |
+| Completion claims and verification evidence | `ship-gate` |
+| Frontend consuming this API | `react-pro-coder` |
+
+This skill owns the service-side engineering decision: layering, schemas, transaction boundaries, async
+model, error surface, and what the API is allowed to return.
diff --git a/skills/python-pro-coder/references/ASYNC-PERFORMANCE.md b/skills/python-pro-coder/references/ASYNC-PERFORMANCE.md
new file mode 100644
index 0000000..aa61c00
--- /dev/null
+++ b/skills/python-pro-coder/references/ASYNC-PERFORMANCE.md
@@ -0,0 +1,74 @@
+# Async and Performance (AS-1 .. AS-5)
+
+## AS-1: Pick one execution model per route and hold it
+
+FastAPI runs `def` routes in a threadpool and `async def` routes on the event loop. Both are correct.
+Mixing them wrongly is what hurts: an `async def` route that calls blocking code stalls the loop for
+every other request in the process.
+
+| Route | Database driver | HTTP client | Verdict |
+|---|---|---|---|
+| `async def` | asyncpg via SQLAlchemy async | httpx async | correct |
+| `def` | psycopg sync | requests | correct, runs in the threadpool |
+| `async def` | sync session or `requests` | any | broken, blocks the loop |
+
+## AS-2: Never block inside `async def`
+
+No `time.sleep`, no synchronous `requests`, no blocking file IO, no CPU-heavy loop.
+
+```python
+from fastapi.concurrency import run_in_threadpool
+
+@router.post("/render")
+async def render(payload: RenderRequest):
+ return await run_in_threadpool(expensive_sync_render, payload)
+```
+
+CPU-bound work does not belong in the web process at all past a certain size; the threadpool is a
+bridge for library calls that have no async form, not a substitute for a worker.
+
+## AS-3: Faster JSON when payloads justify it
+
+```python
+from fastapi.responses import ORJSONResponse
+
+app = FastAPI(default_response_class=ORJSONResponse)
+```
+
+Worth it for large or hot payloads. Measure first: for a 2 KB response the serializer is not the
+bottleneck, and the change costs a dependency plus a subtle difference in how some types serialize.
+
+## AS-4: Pagination is mandatory on list endpoints
+
+```python
+@router.get("/", response_model=Page[UserResponse])
+async def list_users(pagination: Pagination, service: UserServiceDep) -> Page[UserResponse]:
+ ...
+```
+
+An unbounded list endpoint is a denial of service with a friendly name: it works for a year, then one
+tenant reaches a million rows and the endpoint takes the process down. Bound the limit in the schema
+(DI-2) so the bound cannot be bypassed by a query string.
+
+Keyset pagination beats offset for large or frequently mutated tables: offset scans everything it
+skips, and rows shift under a paging client.
+
+## AS-5: Kill N+1 at the query, not in a loop
+
+```python
+# BAD: one query for users, then one per user
+users = (await db.execute(select(User))).scalars().all()
+for user in users:
+ _ = user.posts
+
+# GOOD
+stmt = select(User).options(selectinload(User.posts))
+users = (await db.execute(stmt)).scalars().all()
+```
+
+`selectinload` issues a second query with an `IN` clause and is the default choice for collections.
+`joinedload` uses one query with a join and suits many-to-one. Lazy loading on an async session raises
+rather than silently emitting IO, which is a feature: it turns an N+1 into an error during development.
+
+Log or assert query counts in tests for the endpoints that matter. N+1 regressions arrive through
+unrelated changes.
diff --git a/skills/python-pro-coder/references/CONFIG-VALIDATION.md b/skills/python-pro-coder/references/CONFIG-VALIDATION.md
new file mode 100644
index 0000000..2c96820
--- /dev/null
+++ b/skills/python-pro-coder/references/CONFIG-VALIDATION.md
@@ -0,0 +1,63 @@
+# Settings and Validation (CF-1 .. CF-5)
+
+## CF-1: One `BaseSettings` object, no scattered `os.getenv`
+
+```python
+from pydantic import Field, PostgresDsn
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="forbid")
+
+ database_url: PostgresDsn
+ secret_key: str = Field(min_length=32)
+ debug: bool = False
+```
+
+A missing or malformed variable fails at construction with the field name, not at 3am inside a request
+handler. `extra="forbid"` catches the typo'd variable name that would otherwise be silently ignored.
+
+## CF-2: Cache settings behind a dependency
+
+```python
+from functools import lru_cache
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
+SettingsDep = Annotated[Settings, Depends(get_settings)]
+```
+
+Pick this or a module-level singleton, not both. The dependency form is preferred because a test can
+override it with `app.dependency_overrides[get_settings]`, which a module-level `settings = Settings()`
+cannot offer without monkeypatching.
+
+## CF-3: Validate at the edge, trust inside
+
+Pydantic validates at the HTTP boundary and at every other input boundary: queue messages, webhook
+payloads, external API responses, config files. Past that boundary the types are true, and a service
+that re-checks `if not isinstance(...)` is admitting it does not believe its own signature.
+
+The corollary: anything that enters without passing a model is not validated. Parse it into a model
+first.
+
+## CF-4: Forbid extra fields on input
+
+```python
+class UserCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+```
+
+Default Pydantic behavior ignores unknown fields, so a client sending `{"email": ..., "is_admin": true}`
+gets a silent success and the field vanishes. `extra="forbid"` turns that into a 422. Response models
+are the opposite case: they should not forbid, they should simply not contain what must not leave.
+
+## CF-5: Use the precise type, not `str`
+
+`EmailStr`, `HttpUrl`, `AnyUrl`, `PostgresDsn`, `UUID`, `Decimal` for money, `datetime` with timezone
+awareness, `SecretStr` for anything that must not appear in a log or a repr. Each one is a validation
+rule and a piece of documentation obtained for free, and `SecretStr` in particular prevents the class
+of incident where a settings dump lands in a log line.
+
+Money is `Decimal` with explicit `max_digits` and `decimal_places`. Never `float`.
diff --git a/skills/python-pro-coder/references/DATABASE.md b/skills/python-pro-coder/references/DATABASE.md
new file mode 100644
index 0000000..62c4bbf
--- /dev/null
+++ b/skills/python-pro-coder/references/DATABASE.md
@@ -0,0 +1,67 @@
+# Database and ORM (DB-1 .. DB-5)
+
+## DB-1: SQLAlchemy 2.0 style
+
+```python
+from sqlalchemy.orm import Mapped, mapped_column
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
+```
+
+`Mapped[...]` plus `mapped_column` gives the type checker real column types and makes nullability
+explicit in the annotation. Queries use `select()` with `await db.execute(...)`, not the legacy
+`Query` API.
+
+## DB-2: One persistence pattern per codebase
+
+Either SQLAlchemy models with separate Pydantic schemas, or SQLModel unifying both. Both work. Three
+half-migrated patterns in one repository means every developer must guess which one applies to the file
+they opened.
+
+SQLModel is maintained but still pre-1.0; the separate-model shape stays the default when the table and
+the API schema diverge, which they do as soon as there is a computed field, a hidden column, or a
+different name on the wire.
+
+## DB-3: Alembic migrations, always
+
+`Base.metadata.create_all()` is for a test fixture and nothing else. It cannot alter, cannot backfill,
+cannot roll back, and cannot tell two environments apart.
+
+Every migration is reviewed like code: it has a downgrade, it does not lock a large table for minutes,
+and index creation on a busy Postgres table is concurrent. A migration that drops a column ships after
+the code that stopped reading it, never with it.
+
+## DB-4: Repository per aggregate for testable queries
+
+```python
+class UserRepository:
+ def __init__(self, db: AsyncSession) -> None:
+ self.db = db
+
+ async def get_by_email(self, email: str) -> User | None:
+ return (await self.db.execute(select(User).where(User.email == email))).scalar_one_or_none()
+```
+
+The repository holds query construction so the service reads as business rules and the queries can be
+exercised directly. It does not commit and does not decide policy.
+
+## DB-5: The service owns the transaction
+
+The unit of work is a business operation, not an HTTP request and not a single query. The service
+opens, commits, and rolls back; the dependency provides the session (DI-3); the repository never
+commits.
+
+```python
+async def transfer(self, src: UUID, dst: UUID, amount: Decimal) -> None:
+ async with self.db.begin():
+ await self.accounts.debit(src, amount)
+ await self.accounts.credit(dst, amount)
+```
+
+When two writes must both happen or neither, that fact is expressed once, in the service, and is
+visible to a reader.
diff --git a/skills/python-pro-coder/references/DEPENDENCIES.md b/skills/python-pro-coder/references/DEPENDENCIES.md
new file mode 100644
index 0000000..3560741
--- /dev/null
+++ b/skills/python-pro-coder/references/DEPENDENCIES.md
@@ -0,0 +1,54 @@
+# Dependency Injection (DI-1 .. DI-4)
+
+## DI-1: One dependency, one job
+
+Authentication, session provision, pagination parsing, tenant resolution: separate dependencies, each
+testable and overridable alone. A dependency that authenticates and also opens a session and also logs
+cannot be reused for the route that needs only one of those.
+
+## DI-2: Dependencies for cross-cutting request shapes
+
+```python
+class PaginationParams(BaseModel):
+ offset: int = Field(0, ge=0)
+ limit: int = Field(20, ge=1, le=100)
+
+Pagination = Annotated[PaginationParams, Depends()]
+
+@router.get("/", response_model=Page[UserResponse])
+async def list_users(pagination: Pagination, service: UserServiceDep):
+ ...
+```
+
+Pagination, filtering, and sorting are the same three parameters on every list endpoint. Defining them
+once fixes the bounds once, which is what stops the endpoint that forgot its `le=100`.
+
+## DI-3: The session dependency owns the session lifecycle, the service owns the transaction
+
+```python
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
+ finally:
+ await session.close()
+```
+
+Note what is absent: the commit. A dependency that commits on the way out commits whatever the handler
+left behind, including a half-finished multi-step operation, and it removes the service's ability to
+decide that two writes are one unit. The service commits (DB-5).
+
+Never write a bare `except:`; it swallows `KeyboardInterrupt` and `CancelledError`, and under an async
+server that turns a shutdown into a hang.
+
+## DI-4: `yield` dependencies for anything that must be released
+
+Sessions, locks, tracing spans, temporary files, acquired pool connections. The teardown runs after the
+response, so it is also where per-request cleanup belongs.
+
+Two constraints worth knowing: teardown code after `yield` runs after the response has been sent, so it
+cannot change the response; and an exception raised in teardown is not the client's error, it is a
+server error that needs its own log line (OB-1).
diff --git a/skills/python-pro-coder/references/ENDPOINTS.md b/skills/python-pro-coder/references/ENDPOINTS.md
new file mode 100644
index 0000000..31c674a
--- /dev/null
+++ b/skills/python-pro-coder/references/ENDPOINTS.md
@@ -0,0 +1,72 @@
+# Endpoint Design (EP-1 .. EP-6)
+
+## EP-1: Explicit `response_model` and status code
+
+```python
+@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def create_user(payload: UserCreate, service: UserServiceDep) -> UserResponse:
+ return await service.create(payload)
+```
+
+The response model is the filter that stops internal fields reaching the client (SE-6) and the source
+of the documented schema. Use `status.HTTP_*` constants, not bare integers: `201`, `202`, and `204`
+carry meaning that a reader should not have to look up.
+
+## EP-2: `Annotated` dependencies
+
+```python
+from typing import Annotated
+
+CurrentUser = Annotated[User, Depends(get_current_user)]
+DbSession = Annotated[AsyncSession, Depends(get_db)]
+
+@router.get("/me", response_model=UserResponse)
+async def get_me(user: CurrentUser) -> UserResponse:
+ return user
+```
+
+The alias is defined once and reused across every route, the signature stays readable, and the type is
+visible to the type checker instead of hidden behind a default value.
+
+## EP-3: Path, query, and header metadata belongs in the parameter
+
+```python
+@router.get("/{user_id}", response_model=UserResponse)
+async def get_user(
+ user_id: Annotated[UUID, Path(description="User id")],
+ include_deleted: Annotated[bool, Query(description="Include soft-deleted rows")] = False,
+) -> UserResponse:
+ ...
+```
+
+The default goes on the parameter with `=`, never inside `Query(...)`. `Query(default=False)` inside
+`Annotated` raises `AssertionError` at import time: FastAPI refuses the ambiguity of two default
+sources.
+
+## EP-4: `BackgroundTasks` for non-critical side effects
+
+```python
+@router.post("/", response_model=UserResponse, status_code=201)
+async def create_user(payload: UserCreate, background_tasks: BackgroundTasks, service: UserServiceDep):
+ user = await service.create(payload)
+ background_tasks.add_task(send_welcome_email, user.email)
+ return user
+```
+
+Background tasks run in the same process after the response is sent. They are right for a welcome
+email and wrong for anything that must survive a restart, be retried, or be observed. That work goes to
+a real queue.
+
+## EP-5: Three layers, one direction
+
+Router to service to repository. The router does not query, the repository does not decide, and
+nothing calls back up the stack. A fourth layer needs a stated reason.
+
+## EP-6: Version the API from day one
+
+```python
+app.include_router(users_router, prefix="/api/v1")
+```
+
+Adding a version prefix later means every client changes at once. Adding it now costs one string. The
+version is a URL prefix on the mount, not a per-route decoration.
diff --git a/skills/python-pro-coder/references/ERRORS.md b/skills/python-pro-coder/references/ERRORS.md
new file mode 100644
index 0000000..3acddf7
--- /dev/null
+++ b/skills/python-pro-coder/references/ERRORS.md
@@ -0,0 +1,59 @@
+# Error Handling (ER-1 .. ER-4)
+
+## ER-1: `HTTPException` for expected HTTP outcomes, domain exceptions for domain failures
+
+The service layer should not import `HTTPException`. It raises what it means, and one handler maps
+domain errors to responses at the edge.
+
+```python
+class AppError(Exception):
+ def __init__(self, message: str, status_code: int = 400, code: str | None = None):
+ self.message = message
+ self.status_code = status_code
+ self.code = code
+
+@app.exception_handler(AppError)
+async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"detail": exc.message, "code": exc.code},
+ )
+```
+
+This keeps the service usable from a worker, a CLI, or a test without an HTTP layer, and gives one
+place to change the error envelope.
+
+## ER-2: Never leak internals in production
+
+An unhandled exception returns a generic 500 with a correlation id; the traceback goes to the logs and
+the error tracker. Debug pages, SQL text, file paths, and library versions in a response body are
+reconnaissance material.
+
+```python
+@app.exception_handler(Exception)
+async def unhandled(request: Request, exc: Exception) -> JSONResponse:
+ logger.exception("unhandled_error", path=request.url.path, request_id=get_request_id())
+ return JSONResponse(status_code=500, content={"detail": "Internal server error",
+ "request_id": get_request_id()})
+```
+
+The correlation id in the body is what makes a user's report findable in the logs (OB-2).
+
+## ER-3: Let validation errors be validation errors
+
+FastAPI already returns 422 with the failing field, its location, and the reason. Catching
+`ValidationError` to reformat it into `{"error": "bad request"}` destroys the only part clients can act
+on. Override `RequestValidationError` only to reshape the envelope consistently, never to flatten the
+detail away.
+
+Inside validators raise `ValueError`, not `HTTPException`: Pydantic collects the former into the same
+422 with field context (PD-5).
+
+## ER-4: Make writes idempotent where a retry is plausible
+
+Any `POST` that creates a resource will be retried: by a proxy, by a mobile client on a flaky network,
+by an impatient user. Accept an `Idempotency-Key` header, store the key with the created resource id,
+and return the original result on a repeat.
+
+For internal callers, a natural key with a unique constraint achieves the same thing more cheaply.
+Either way, the second identical request must not create the second row.
diff --git a/skills/python-pro-coder/references/OBSERVABILITY.md b/skills/python-pro-coder/references/OBSERVABILITY.md
new file mode 100644
index 0000000..329b8f9
--- /dev/null
+++ b/skills/python-pro-coder/references/OBSERVABILITY.md
@@ -0,0 +1,103 @@
+# Production and Observability (OB-1 .. OB-7)
+
+## OB-1: Structured logging
+
+```python
+import structlog
+
+log = structlog.get_logger()
+log.info("user_created", user_id=str(user.id), tenant_id=tenant.id, duration_ms=elapsed)
+```
+
+Events are named, fields are typed key-values, output is JSON in production and human-readable
+locally. An interpolated sentence cannot be filtered, aggregated, or alerted on.
+
+Never log secrets, tokens, full request bodies containing personal data, or password fields. `SecretStr`
+(CF-5) makes that harder to do by accident.
+
+## OB-2: Request id and timing middleware
+
+Accept an inbound `X-Request-ID` when present, generate one when absent, bind it into the log context
+for the request, return it on the response, and propagate it to downstream calls. Log method, path,
+status, and duration once per request.
+
+This is what turns a user's screenshot of an error into a log query (ER-2).
+
+## OB-3: Health and readiness are different endpoints
+
+```python
+@router.get("/health")
+async def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+@router.get("/ready")
+async def ready(db: DbSession) -> dict[str, str]:
+ await db.execute(text("SELECT 1"))
+ return {"status": "ready"}
+```
+
+Liveness answers "is the process alive", so it touches nothing. Readiness answers "can it serve
+traffic", so it checks the dependencies it cannot work without. Wiring the database into liveness is
+how one slow query restarts every pod at once.
+
+Keep readiness cheap and give it a timeout: a probe that hangs is a probe that fails.
+
+## OB-4: Tracing and error tracking
+
+OpenTelemetry spans across the request, the database calls, and outbound HTTP; Sentry or equivalent for
+exceptions with the request id attached. Sample traces in production rather than dropping them
+entirely, because the trace you need is the slow one, and tail-based sampling is what keeps it.
+
+## OB-5: Control what the docs expose
+
+```python
+app = FastAPI(
+ docs_url="/docs" if settings.debug else None,
+ redoc_url=None,
+ openapi_url="/openapi.json" if settings.debug else None,
+)
+```
+
+Public interactive docs advertise the whole surface, including admin routes. Either disable them
+outside development or put them behind the same auth as the rest.
+
+Where docs stay on, invest in them: `description`, `summary`, `response_model`, examples, and
+`Annotated` metadata are what make the generated schema usable by a client generator.
+
+## OB-6: Timeouts everywhere, and a graceful shutdown
+
+Every outbound HTTP client, database pool, and cache client gets an explicit timeout. A default of
+"wait forever" turns one slow dependency into a saturated worker pool.
+
+Shutdown drains in-flight requests inside the `lifespan` teardown (PS-4), closes pools, and flushes
+telemetry, bounded by a deadline shorter than the orchestrator's kill timeout.
+
+## OB-7: Container image and process model
+
+```dockerfile
+FROM python:3.13-slim AS builder
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-dev --no-install-project
+COPY . .
+RUN uv sync --frozen --no-dev --no-editable
+
+FROM python:3.13-slim
+RUN useradd -m -u 10001 appuser
+WORKDIR /app
+COPY --from=builder --chown=appuser:appuser /app /app
+ENV PATH="/app/.venv/bin:$PATH"
+USER appuser
+EXPOSE 8000
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+```
+
+Multi-stage keeps build tooling out of the runtime image, dependencies install before the source copy
+so the layer cache survives a code change, and the process runs as a non-root user.
+
+Process model: on Kubernetes, one worker per pod and let replicas scale, since the orchestrator is
+already a process manager. On a bare VM or a single container, Gunicorn with
+`uvicorn.workers.UvicornWorker` and workers roughly equal to available cores. Pick from the target
+environment, not from a blog post.
diff --git a/skills/python-pro-coder/references/OUTPUT-CONTRACT.md b/skills/python-pro-coder/references/OUTPUT-CONTRACT.md
new file mode 100644
index 0000000..ab789a9
--- /dev/null
+++ b/skills/python-pro-coder/references/OUTPUT-CONTRACT.md
@@ -0,0 +1,53 @@
+# Output Contract and Negative Doubt
+
+## The contract
+
+Every substantive response includes these sections in order. A section that genuinely does not apply is
+named and marked "not applicable", never dropped silently.
+
+1. **Task classification** - exactly one of New Feature, Refactor, Bug Fix, Performance, Review/Audit,
+ Documentation Only.
+2. **Environment verification** - commands run and their output, or the stated defaults when the
+ environment could not be inspected. Include the versions that matter to the answer.
+3. **Assumptions** - every one that would change the design if wrong.
+4. **Architecture decision** - layer placement, transaction boundary, async or sync, where validation
+ happens, what owns the data.
+5. **API surface** - routes with methods, status codes, request and response schemas, error responses.
+6. **Data model and migration** - table changes and the Alembic step, or "no schema change".
+7. **Code** - by file path, complete, no placeholders in shipped code.
+8. **Tests** - runnable as given, covering success, validation failure, authorization failure, and the
+ not-found case where one exists.
+9. **Negative doubt log** - the output of the routine below.
+10. **Risks and trade-offs** - what this design gives up and when to revisit it.
+
+## Negative doubt routine
+
+Run after drafting, before answering.
+
+| Pass | What it does |
+|---|---|
+| Fail-seeking | Name 5 concrete failures: malformed input, missing auth, concurrent write, duplicate retry, dependency timeout, empty result, oversized payload |
+| Assumption falsification | Take each assumption and ask what would disprove it. Unverifiable ones become explicit questions |
+| Invariant enforcement | For each invariant, show the constraint, validator, or type that makes violation unrepresentable. Database constraints outrank application checks |
+| Boundary audit | Layer direction one way, no `HTTPException` below the router, no queries above the repository |
+| Blocking audit | Every call inside `async def` is awaitable or explicitly offloaded (AS-2) |
+| Leak audit | Response models contain nothing internal, logs contain no secrets |
+| Simpler alternative | State the simpler design and why it was rejected. Weak reason means take the simpler design |
+| Test injection | At least one test per failure mode found above |
+| Revision | Apply fixes, then repeat the routine once |
+
+## Log format
+
+```
+Failure modes considered:
+1. - covered by | accepted because
+...
+Assumptions falsified:
+Simpler alternative rejected: because
+Remaining uncertainty:
+```
+
+## Hard stop
+
+If correctness or safety is still uncertain after the second pass, do not finalize. Return the revised
+design, the missing inputs, and the question that unblocks it.
diff --git a/skills/python-pro-coder/references/PROJECT-STRUCTURE.md b/skills/python-pro-coder/references/PROJECT-STRUCTURE.md
new file mode 100644
index 0000000..f5fafe6
--- /dev/null
+++ b/skills/python-pro-coder/references/PROJECT-STRUCTURE.md
@@ -0,0 +1,83 @@
+# Project Structure (PS-1 .. PS-5)
+
+## PS-1: Feature-based structure, not layer-based
+
+```
+app/
+ features/
+ users/
+ router.py # HTTP surface only
+ schemas.py # Pydantic request and response models
+ service.py # business rules, transaction boundary
+ repository.py # queries
+ models.py # SQLAlchemy tables
+ auth/
+ core/
+ config.py
+ security.py
+ db.py
+ main.py
+```
+
+not `app/routers/`, `app/models/`, `app/schemas/` at the top. Layer folders scatter one feature across
+five directories, so every change is a five-file diff and deleting a feature is never clean. Shared
+code lives in `core/`, and the test for putting something there is that a second feature already
+imports it.
+
+## PS-2: One router per feature
+
+Each feature owns its `APIRouter` with its own prefix and tags, and `main.py` mounts it.
+
+```python
+# features/users/router.py
+router = APIRouter(prefix="/users", tags=["users"])
+
+# main.py
+app.include_router(users_router, prefix="/api/v1")
+```
+
+Tags are what OpenAPI groups by, so they are part of the API surface, not decoration.
+
+## PS-3: Routers are thin
+
+Parse, delegate, return. No queries, no business branching, no transaction control in the route
+function.
+
+```python
+# BAD
+@router.get("/{user_id}")
+async def get_user(user_id: UUID, db: DbSession):
+ return (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+
+# GOOD
+@router.get("/{user_id}", response_model=UserResponse)
+async def get_user(user_id: UUID, service: UserServiceDep) -> UserResponse:
+ return await service.get_by_id(user_id)
+```
+
+A router that only translates HTTP to a service call can be read in one breath, and the service can be
+tested without an HTTP client.
+
+## PS-4: `lifespan`, not `on_event`
+
+`@app.on_event` has been deprecated since FastAPI 0.93.
+
+```python
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_db()
+ yield
+ await close_db()
+
+app = FastAPI(lifespan=lifespan)
+```
+
+Everything acquired before the `yield` is released after it, in reverse order. That includes HTTP
+client pools, database engines, and background schedulers.
+
+## PS-5: `main.py` wires, it does not decide
+
+App construction, router mounting, middleware, exception handlers. No business logic, no queries, no
+inline dependency definitions. When `main.py` grows past a screen, the growth belongs somewhere else.
diff --git a/skills/python-pro-coder/references/PYDANTIC.md b/skills/python-pro-coder/references/PYDANTIC.md
new file mode 100644
index 0000000..dfedf4c
--- /dev/null
+++ b/skills/python-pro-coder/references/PYDANTIC.md
@@ -0,0 +1,138 @@
+# Pydantic v2 (PD-1 .. PD-8)
+
+## PD-1: v2 syntax only
+
+| v1 | v2 |
+|---|---|
+| `class Config:` | `model_config = ConfigDict(...)` |
+| `orm_mode = True` | `from_attributes=True` |
+| `@validator` | `@field_validator` (plus `@classmethod`) |
+| `@root_validator` | `@model_validator(mode="before" \| "after")` |
+| `.dict()`, `.json()` | `.model_dump()`, `.model_dump_json()` |
+| `.parse_obj()`, `.parse_raw()` | `.model_validate()`, `.model_validate_json()` |
+| `.schema()` | `.model_json_schema()` |
+
+Mixed v1 and v2 syntax in one codebase is a migration that stalled. Finish it.
+
+## PD-2: Separate input, output, and persistence models
+
+```python
+class UserCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ email: EmailStr
+ password: str = Field(min_length=8)
+
+class UserUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ email: EmailStr | None = None
+
+class UserResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ email: EmailStr
+ created_at: datetime
+
+class UserInDB(BaseModel):
+ id: UUID
+ hashed_password: str
+```
+
+One model for all three roles means either the API leaks a column it should not (SE-6) or the table
+carries a field it does not need. The duplication is the point: request shape, response shape, and row
+shape change for different reasons.
+
+## PD-3: `Field` for anything that needs validation or documentation
+
+```python
+class ProductCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=100, description="Product name")
+ price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
+ tags: list[str] = Field(default_factory=list, max_length=10)
+```
+
+`description` lands in the OpenAPI schema, so the validation and the documentation stay in one place
+and cannot drift apart. Never use a mutable default directly; `default_factory` exists for that.
+
+## PD-4: `Annotated` for reusable constrained types
+
+```python
+from typing import Annotated
+
+Email = Annotated[EmailStr, Field(description="User email")]
+Age = Annotated[int, Field(ge=0, le=150)]
+Money = Annotated[Decimal, Field(gt=0, max_digits=12, decimal_places=2)]
+
+class UserCreate(BaseModel):
+ email: Email
+ age: Age
+```
+
+Define the constraint once and the same rule applies everywhere the type is used, including nested
+models and route parameters.
+
+## PD-5: `field_validator` and `model_validator`, default to `mode="after"`
+
+```python
+class UserCreate(BaseModel):
+ password: str
+ password_confirm: str
+
+ @field_validator("password")
+ @classmethod
+ def password_strong(cls, v: str) -> str:
+ if len(v) < 8 or not any(c.isdigit() for c in v):
+ raise ValueError("password must be at least 8 characters and contain a digit")
+ return v
+
+ @model_validator(mode="after")
+ def passwords_match(self):
+ if self.password != self.password_confirm:
+ raise ValueError("passwords do not match")
+ return self
+```
+
+`mode="after"` receives parsed, typed values. `mode="before"` receives raw input and is only for
+reshaping payloads that arrive in the wrong form. Raise `ValueError`, not `HTTPException`: FastAPI
+turns the former into a 422 with field-level detail (ER-3).
+
+## PD-6: `computed_field` for derived data
+
+```python
+class UserResponse(BaseModel):
+ first_name: str
+ last_name: str
+
+ @computed_field
+ @property
+ def full_name(self) -> str:
+ return f"{self.first_name} {self.last_name}"
+```
+
+Derived values are computed, not stored, and they still appear in the OpenAPI schema. A stored copy of
+a derivable value is two sources of truth waiting to disagree.
+
+## PD-7: Strict mode where coercion would hide a bug
+
+```python
+class FeatureFlags(BaseModel):
+ model_config = ConfigDict(strict=True)
+ max_items: int
+ enabled: bool
+```
+
+In lax mode `"1"` becomes `1` and `"yes"` can become `True`. That is convenient for a form post and
+dangerous for a config file or an internal message. Set `strict=True` on the model rather than
+scattering `StrictInt` and `StrictBool` field types; both work, one is consistent.
+
+## PD-8: `model_config` settings that carry weight
+
+| Setting | Effect | Use on |
+|---|---|---|
+| `from_attributes=True` | reads ORM objects | response models |
+| `extra="forbid"` | rejects unknown fields | every input model (CF-4) |
+| `str_strip_whitespace=True` | trims strings | input models |
+| `frozen=True` | immutable, hashable | value objects, response models |
+| `populate_by_name=True` | accepts field name and alias | models with camelCase aliases |
+
+Set these deliberately per model. A blanket base class that turns everything on removes the ability to
+say what a specific model needs.
diff --git a/skills/python-pro-coder/references/REVIEW-CHECKLIST.md b/skills/python-pro-coder/references/REVIEW-CHECKLIST.md
new file mode 100644
index 0000000..3a7220e
--- /dev/null
+++ b/skills/python-pro-coder/references/REVIEW-CHECKLIST.md
@@ -0,0 +1,99 @@
+# Review and Audit Checklist
+
+Run top to bottom. Report each finding as `path:line - RULE-ID - problem - fix`. Rules that do not
+apply are skipped silently; a deliberate waiver is reported with its reason.
+
+## Structure
+
+- [ ] PS-1 Feature folders, not top-level layer folders
+- [ ] PS-3 Routers thin: no queries, no business branching
+- [ ] PS-4 `lifespan`, no `@app.on_event`
+- [ ] PS-5 `main.py` wires only
+
+## Pydantic
+
+- [ ] PD-1 No v1 syntax: `class Config`, `@validator`, `.dict()`, `.parse_obj()`
+- [ ] PD-2 Create, Update, Response, and DB models separate
+- [ ] PD-3 `Field` constraints and descriptions where they matter, `default_factory` for mutables
+- [ ] PD-5 Validators raise `ValueError`, `mode="after"` unless raw reshaping is needed
+- [ ] PD-6 Derived values computed, not stored
+- [ ] PD-8 `from_attributes` on response models, `extra="forbid"` on inputs
+
+## Endpoints
+
+- [ ] EP-1 `response_model` and explicit status code on every route
+- [ ] EP-2 `Annotated` dependency aliases
+- [ ] EP-3 Defaults with `=`, never `Query(default=...)` inside `Annotated`
+- [ ] EP-4 Background tasks only for work that may be lost
+- [ ] EP-5 Router to service to repository, one direction
+- [ ] EP-6 Version prefix on the mount
+
+## Settings
+
+- [ ] CF-1 One `BaseSettings`, no scattered `os.getenv`
+- [ ] CF-2 One settings access pattern, overridable in tests
+- [ ] CF-4 `extra="forbid"` on input models
+- [ ] CF-5 Precise types: `EmailStr`, `HttpUrl`, `UUID`, `Decimal` for money, `SecretStr` for secrets
+
+## Dependencies
+
+- [ ] DI-1 One dependency, one job
+- [ ] DI-3 Session dependency rolls back and closes, does not commit
+- [ ] DI-3 No bare `except:`
+- [ ] DI-4 Everything acquired is released after `yield`
+
+## Async and performance
+
+- [ ] AS-1 Execution model consistent per route
+- [ ] AS-2 No blocking call inside `async def`
+- [ ] AS-4 Every list endpoint paginated with a bounded limit
+- [ ] AS-5 Eager loading where a relationship is used, no N+1
+
+## Security
+
+- [ ] SE-1 `pwdlib`, not `passlib`, hashes never returned or logged
+- [ ] SE-2 Maintained JWT library, explicit `algorithms`, `exp`/`iss`/`aud` verified, short lifetimes
+- [ ] SE-3 Built-in security utilities, authorization in a dependency
+- [ ] SE-4 CORS origins, methods, and headers listed explicitly
+- [ ] SE-5 Rate limit on auth and expensive endpoints, keyed on something trustworthy
+- [ ] SE-6 No internal model or ORM object returned
+
+## Errors
+
+- [ ] ER-1 Service layer free of `HTTPException`, domain errors mapped at the edge
+- [ ] ER-2 No traceback, SQL, or path in a production response; correlation id present
+- [ ] ER-3 422 detail preserved, validators raise `ValueError`
+- [ ] ER-4 Creating writes idempotent where retries are plausible
+
+## Database
+
+- [ ] DB-1 SQLAlchemy 2.0 style, `select()` not legacy `Query`
+- [ ] DB-2 One persistence pattern
+- [ ] DB-3 Alembic migration with a downgrade, no `create_all` outside tests
+- [ ] DB-4 Queries in repositories
+- [ ] DB-5 Transaction boundary in the service, repository never commits
+
+## Testing and quality
+
+- [ ] TQ-1 `ASGITransport`, real database, one async plugin
+- [ ] TQ-3 Contract tests: status code, schema validation, exact response keys
+- [ ] TQ-4 `mypy --strict` or `pyright --strict` green in CI
+- [ ] TQ-5 Ruff lint and format in CI, `ASYNC` and `S` rule sets enabled
+
+## Production
+
+- [ ] OB-1 Structured logs, no secrets
+- [ ] OB-2 Request id propagated and returned
+- [ ] OB-3 Liveness cheap, readiness checks dependencies
+- [ ] OB-5 Docs closed or authenticated outside development
+- [ ] OB-6 Explicit timeouts, graceful shutdown
+- [ ] OB-7 Non-root multi-stage image, process model matched to the platform
+
+## Severity
+
+| Level | Meaning |
+|---|---|
+| Blocker | Data loss, auth bypass, secret exposure, unbounded query on a production path |
+| Major | Rule violation that will cause an incident or an expensive migration |
+| Minor | Rule violation with contained blast radius |
+| Note | Preference or future consideration, explicitly not required |
diff --git a/skills/python-pro-coder/references/SECURITY.md b/skills/python-pro-coder/references/SECURITY.md
new file mode 100644
index 0000000..0cdaed5
--- /dev/null
+++ b/skills/python-pro-coder/references/SECURITY.md
@@ -0,0 +1,78 @@
+# Security (SE-1 .. SE-6)
+
+## SE-1: Password hashing with `pwdlib`
+
+```python
+from pwdlib import PasswordHash
+
+password_hash = PasswordHash.recommended()
+hashed = password_hash.hash(plain)
+ok = password_hash.verify(plain, hashed)
+```
+
+`passlib` is unmaintained and breaks on Python 3.13 and later. Keep it only as a read path while
+migrating existing hashes, and rehash on successful login. Never store a plain or reversibly encrypted
+password, never log the plain value, and keep the hash out of every response model (SE-6).
+
+## SE-2: JWT with a maintained library, and validate every claim
+
+Use `PyJWT`, or `joserfc` when JWE or full JOSE support is needed. `python-jose` is abandoned and
+carries CVE-2024-33664.
+
+| Control | Value |
+|---|---|
+| Access token lifetime | minutes, not hours |
+| Refresh token | rotated on use, revocable, stored server-side |
+| Verified claims | `exp`, `nbf`, `iss`, `aud`, and the signing algorithm |
+| Algorithm | pinned explicitly, never read from the token header |
+
+Decoding without an explicit `algorithms=[...]` list is how `alg: none` and algorithm-confusion attacks
+land. Verify the signature before reading any claim from the payload.
+
+## SE-3: `OAuth2PasswordBearer` and scopes, not hand-rolled header parsing
+
+```python
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login", scopes={"users:read": "Read users"})
+```
+
+The built-in security utilities produce the OpenAPI security scheme, drive the docs authorize button,
+and handle the missing-header and wrong-scheme cases consistently. Authorization decisions live in a
+dependency, so a route cannot forget them by omission.
+
+## SE-4: CORS explicit, never wildcard with credentials
+
+```python
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["https://app.example.com"],
+ allow_credentials=True,
+ allow_methods=["GET", "POST"],
+ allow_headers=["Authorization", "Content-Type"],
+)
+```
+
+`allow_origins=["*"]` together with `allow_credentials=True` is rejected by browsers and is a sign the
+configuration was copied rather than decided. List the origins, list the methods, list the headers.
+
+## SE-5: Rate limit before the expensive work
+
+Login, password reset, token refresh, signup, and any endpoint that sends mail or calls a paid API.
+Prefer the edge (gateway, ingress, CDN) because it protects the process itself. In-process limiting
+with `slowapi` or `fastapi-limiter` is a second layer, and both need shared storage such as Redis to
+mean anything across replicas.
+
+Rate limit by an identity the client cannot trivially change; a bare remote address behind a proxy is
+whatever `X-Forwarded-For` says unless the proxy is trusted and the header is sanitized.
+
+## SE-6: Never return an internal model
+
+The response model is the boundary. Returning an ORM object or an internal Pydantic model is how
+`hashed_password`, `is_admin`, `internal_notes`, and soft-delete flags reach clients.
+
+```python
+@router.get("/{user_id}", response_model=UserResponse)
+```
+
+Two habits enforce it: `response_model` on every route (EP-1), and a test that asserts the response
+body has exactly the expected keys (TQ-3). Field-level exclusion via `response_model_exclude` is a
+patch over a wrong model, not a design.
diff --git a/skills/python-pro-coder/references/STACK-2026.md b/skills/python-pro-coder/references/STACK-2026.md
new file mode 100644
index 0000000..281a60a
--- /dev/null
+++ b/skills/python-pro-coder/references/STACK-2026.md
@@ -0,0 +1,51 @@
+# Stack Facts
+
+Every line here was verified on 2026-09-04 against the package registry or the project's own docs. When
+a version or a maintenance claim drives a recommendation, read this file rather than recalling a
+number. Re-verify anything older than a quarter.
+
+## Versions
+
+| Package | Version | Notes |
+|---|---|---|
+| FastAPI | 0.141.1 | requires Python >= 3.10 |
+| Pydantic | 2.13.5 | v2 only, v1 syntax is a hard stop |
+| pydantic-settings | 2.15.0 | requires Python >= 3.10 |
+| SQLAlchemy | 2.0.52 | 2.0 style: `Mapped`, `mapped_column`, `select()` |
+| Python | 3.14.7 latest stable | free-threaded build officially supported (PEP 779), no longer experimental |
+
+Support floor for a new service: Python 3.12. Python 3.10 only when an existing deployment pins it.
+
+## Dead or dying dependencies
+
+| Package | Status | Replacement |
+|---|---|---|
+| `python-jose` | Abandoned, last release years old. CVE-2024-33664 (JWE decompression denial of service). FastAPI's own docs moved off it | `PyJWT` for JWS and JWT, `joserfc` when JWE, JWK, or full JOSE is needed |
+| `passlib` | Unmaintained, breaks on Python 3.13+ | `pwdlib` with `PasswordHash.recommended()`. Keep passlib only to verify legacy hashes during a migration |
+| `@app.on_event` | Deprecated since FastAPI 0.93 | `lifespan` async context manager |
+| `AsyncClient(app=...)` | Deprecated in httpx | `AsyncClient(transport=ASGITransport(app=app))`, needs httpx >= 0.27.2 for clean typing |
+| `datetime.utcnow()` | Deprecated since Python 3.12 | `datetime.now(UTC)` |
+
+## Choices that are genuinely open
+
+| Decision | Options | How to choose |
+|---|---|---|
+| Type checker | mypy, pyright, ty, pyrefly | mypy or pyright in strict mode is the safe default. Astral's `ty` is fast but still beta with a 1.0 targeted for 2026; treat it as an extra check, not the gate |
+| Async test plugin | pytest-asyncio, anyio | pytest-asyncio with `asyncio_mode = "auto"` for an asyncio-only service. anyio when the code must also run on Trio. Running both plugins in auto mode conflicts, pick one |
+| ORM shape | SQLAlchemy + separate Pydantic schemas, or SQLModel | SQLAlchemy plus separate schemas for anything with a non-trivial schema divergence between table and API. SQLModel is maintained and convenient, still pre-1.0 (0.0.x). Pick one and do not mix three shapes (DB-2) |
+| Rate limiting | slowapi, fastapi-limiter, gateway or ingress level | Prefer the edge (gateway, ingress, CDN). In-process, `slowapi` is decorator-based and self-describes as alpha quality; `fastapi-limiter` is Redis-backed and dependency-based. Either is acceptable, neither is a substitute for an edge limit (SE-5) |
+| Process model | Gunicorn with Uvicorn workers, or Uvicorn directly | Bare VM or single container: Gunicorn with `uvicorn.workers.UvicornWorker`, workers roughly equal to cores. Kubernetes: one Uvicorn worker per pod and let the orchestrator scale replicas (OB-7) |
+| JSON response class | stdlib, `ORJSONResponse` | `ORJSONResponse` when payloads are large or hot. Measure before assuming it matters |
+
+## Sources
+
+- https://pypi.org/project/fastapi/
+- https://pypi.org/project/pydantic/
+- https://pypi.org/project/pydantic-settings/
+- https://pypi.org/project/sqlalchemy/
+- https://www.python.org/downloads/
+- https://fastapi.tiangolo.com/advanced/async-tests/
+- https://github.com/fastapi/fastapi/discussions/11345 (python-jose replacement)
+- https://github.com/fastapi/fastapi/discussions/11773 (passlib status)
+- https://github.com/frankie567/pwdlib/discussions/1
+- https://docs.astral.sh/uv/guides/integration/docker/
diff --git a/skills/python-pro-coder/references/TEMPLATES.md b/skills/python-pro-coder/references/TEMPLATES.md
new file mode 100644
index 0000000..3f739c5
--- /dev/null
+++ b/skills/python-pro-coder/references/TEMPLATES.md
@@ -0,0 +1,162 @@
+# Scaffolds
+
+Starting points, not paste-and-ship. Every scaffold is held to the rules in the other reference files.
+
+## Settings
+
+```python
+from functools import lru_cache
+from pydantic import Field, PostgresDsn, SecretStr
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", extra="forbid")
+
+ environment: str = "local"
+ debug: bool = False
+ database_url: PostgresDsn
+ secret_key: SecretStr = Field(min_length=32)
+ access_token_ttl_seconds: int = Field(900, ge=60, le=3600)
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+```
+
+## Database session dependency
+
+```python
+from collections.abc import AsyncGenerator
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+engine = create_async_engine(str(settings.database_url), pool_pre_ping=True)
+async_session = async_sessionmaker(engine, expire_on_commit=False)
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
+ finally:
+ await session.close()
+
+DbSession = Annotated[AsyncSession, Depends(get_db)]
+```
+
+`expire_on_commit=False` keeps loaded attributes usable after commit, which matters when the response
+model reads the object the service just wrote.
+
+## Feature router
+
+```python
+router = APIRouter(prefix="/users", tags=["users"])
+
+@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def create_user(payload: UserCreate, service: UserServiceDep) -> UserResponse:
+ return await service.create(payload)
+
+@router.get("/{user_id}", response_model=UserResponse)
+async def get_user(
+ user_id: Annotated[UUID, Path(description="User id")],
+ service: UserServiceDep,
+) -> UserResponse:
+ return await service.get_by_id(user_id)
+
+@router.get("/", response_model=Page[UserResponse])
+async def list_users(pagination: Pagination, service: UserServiceDep) -> Page[UserResponse]:
+ return await service.list(pagination)
+```
+
+## Paginated response envelope
+
+```python
+from typing import Generic, TypeVar
+from pydantic import BaseModel
+
+T = TypeVar("T")
+
+class Page(BaseModel, Generic[T]):
+ items: list[T]
+ total: int
+ offset: int
+ limit: int
+```
+
+## Application factory
+
+```python
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ await startup_checks()
+ yield
+ await engine.dispose()
+
+def create_app() -> FastAPI:
+ settings = get_settings()
+ app = FastAPI(
+ title="Service",
+ lifespan=lifespan,
+ default_response_class=ORJSONResponse,
+ docs_url="/docs" if settings.debug else None,
+ openapi_url="/openapi.json" if settings.debug else None,
+ )
+ app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins,
+ allow_credentials=True, allow_methods=["GET", "POST"],
+ allow_headers=["Authorization", "Content-Type"])
+ app.add_exception_handler(AppError, app_error_handler)
+ app.include_router(users_router, prefix="/api/v1")
+ app.include_router(health_router)
+ return app
+
+app = create_app()
+```
+
+The factory form is what lets a test build an app with overridden settings instead of importing a
+module-level singleton.
+
+## Test conftest
+
+```python
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+@pytest.fixture
+async def client(app_with_test_db: FastAPI) -> AsyncGenerator[AsyncClient, None]:
+ async with AsyncClient(transport=ASGITransport(app=app_with_test_db),
+ base_url="http://test") as c:
+ yield c
+
+@pytest.fixture
+def override_settings(app_with_test_db: FastAPI):
+ app_with_test_db.dependency_overrides[get_settings] = lambda: Settings(debug=True, ...)
+ yield
+ app_with_test_db.dependency_overrides.clear()
+```
+
+```toml
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+```
+
+## Dockerfile
+
+See OB-7 in `OBSERVABILITY.md` for the multi-stage uv image and the process-model decision.
+
+## pyproject quality block
+
+```toml
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "B", "UP", "ASYNC", "S", "SIM", "RUF"]
+
+[tool.mypy]
+strict = true
+plugins = ["pydantic.mypy"]
+```
diff --git a/skills/python-pro-coder/references/TESTING.md b/skills/python-pro-coder/references/TESTING.md
new file mode 100644
index 0000000..1f05929
--- /dev/null
+++ b/skills/python-pro-coder/references/TESTING.md
@@ -0,0 +1,79 @@
+# Testing and Quality (TQ-1 .. TQ-5)
+
+## TQ-1: Real client, real database
+
+```python
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+@pytest.fixture
+async def client() -> AsyncGenerator[AsyncClient, None]:
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+```
+
+`AsyncClient(app=app)` is deprecated in httpx; the explicit transport is the supported form and needs
+httpx 0.27.2 or later for clean typing. `TestClient` remains fine for synchronous tests.
+
+Run against a real Postgres (testcontainers, or a disposable database in CI), not SQLite and not a
+mocked session. Half the bugs worth catching are dialect, constraint, and transaction behavior, and a
+mock asserts only that the code called what the test expected it to call.
+
+Async plugin: `pytest-asyncio` with `asyncio_mode = "auto"` for an asyncio-only service, or the `anyio`
+plugin when Trio also matters. Both in auto mode conflict; choose one.
+
+## TQ-2: Generated test data, not hand-written dicts
+
+```python
+from polyfactory.factories.pydantic_factory import ModelFactory
+
+class UserCreateFactory(ModelFactory[UserCreate]):
+ __model__ = UserCreate
+
+payload = UserCreateFactory.build(email="known@example.com")
+```
+
+The factory tracks the schema, so adding a required field breaks the factory once rather than breaking
+forty tests. Override only the fields the test is actually about; everything else being arbitrary is
+the point.
+
+## TQ-3: Test the contract, status code and schema
+
+```python
+async def test_create_user(client: AsyncClient) -> None:
+ resp = await client.post("/api/v1/users/", json=UserCreateFactory.build().model_dump(mode="json"))
+ assert resp.status_code == 201
+ body = UserResponse.model_validate(resp.json())
+ assert set(resp.json()) == set(UserResponse.model_fields)
+```
+
+Validating the body against the response model catches a schema regression that a field-by-field
+assertion misses. The exact-keys assertion is what catches a leaked internal field (SE-6).
+
+Every endpoint owes at least: the success case, the validation failure (422), the authorization failure
+(401 or 403), and the not-found case where one exists.
+
+## TQ-4: Strict type checking in CI
+
+`mypy --strict` or `pyright --strict` over the application package, as a gate, not a suggestion.
+FastAPI and Pydantic are fully typed, so strict mode actually finds things: an unawaited coroutine, an
+optional that is never checked, a response model that cannot be built from what the function returns.
+
+Astral's `ty` is fast and still beta; run it as an extra signal if you like, but the gate stays mypy or
+pyright until its 1.0.
+
+## TQ-5: Ruff for lint and format
+
+```toml
+[tool.ruff]
+line-length = 100
+target-version = "py312" # match the project's actual minimum
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "B", "UP", "ASYNC", "S", "SIM", "RUF"]
+```
+
+One tool replaces flake8, isort, and black. `ASYNC` catches blocking calls inside `async def` (AS-2),
+`S` is the bandit security rule set, and `UP` keeps syntax current with the target version. Wire it
+into pre-commit and CI so the gate is not a habit.