All Rework - #7
Open
vianneybacoup wants to merge 90 commits into
Open
Conversation
Design doc complet pour la migration du template vers FastAPI + Clean Architecture avec garde-fous multi-couches (Claude skills, hooks, pre-commit, CI). Issue des sessions de brainstorming /superpowers:brainstorming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Convert dev-soma-template into a Cookiecutter generator with project_name,
slug, package_name, python_version, database, helm/otel/cli flags, license
- pre_gen_project validates inputs (slug regex, supported python, blocks
database=none which is incompatible with the persisted reference feature)
- post_gen_project removes optional dirs, copies .env, runs git init +
uv sync + pre-commit install
- Minimal {{cookiecutter.project_slug}}/ with the four Clean Architecture
layers, a /health FastAPI app, GIVEN/WHEN/THEN smoke test
- Template tooling: pyproject (cookiecutter + pytest-cookies + ruff with
the cookiecutter content tree excluded via force-exclude)
- Bake tests pass: default layout, pyproject correctness, database=none abort
Renamed AGENTS.md to CLAUDE.md per spec. Removed legacy src/, tests/, uv.lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated project now ships: - domain/: exceptions/base.py with DomainError + entities/, value_objects/ - application/: Clock, IdGenerator, RandomSource, EventBus, AuditLog abstractions plus use_cases/, repositories/, dtos/ folders - infrastructure/: SystemClock, Uuid4IdGenerator, SystemRandomSource, Pydantic Settings (with Annotated[NoDecode] on cors_allowed_origins for comma-separated parsing), structlog logging with secret redaction, conditional OpenTelemetry tracing with auto-switching exporter - infrastructure/persistence/: async engine, async sessionmaker, declarative Base, Alembic env (async) + script.py.mako with mandatory non-empty downgrade - infrastructure/container.py: pure factory functions (no DI framework) - presentation/api/: app factory wiring lifespan + middlewares + error handlers, FastAPI Depends wrapping the container, SecurityHeadersMiddleware, centralised DomainError -> HTTP mapping - D1 lints in scripts/checks/: no_third_party_in_domain, no_naive_datetime, no_float_in_domain (scoped to domain/ to allow RandomSource.next_float), test_naming (GIVEN/WHEN/THEN AST checker), quality.sh aggregator Verified: bake -> uv sync -> create_app() -> pytest unit -> all custom lints all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reference feature (examples-by-code in src/, not in examples/): - Email value object with InvalidEmailError + lowercase normalisation - UserId value object wrapping UUID - User entity with identity-based equality - UserAlreadyExistsError, UserNotFoundError domain exceptions - UserRepository abstraction + SqlAlchemyUserRepository implementation - UserModel (declarative SQLAlchemy mapping) - CreateUserUseCase with clock + id-generator collaborators - CreateUserInput / CreateUserOutput frozen dataclass DTOs - UserCreateSchema / UserReadSchema Pydantic schemas - POST /v1/users endpoint and dependency wiring - ERROR_HTTP_MAPPING entries for the 3 domain errors Test infrastructure: - tests/fakes/: FrozenClock, SequentialIdGenerator, SeededRandomSource, InMemoryUserRepository (acts as Spy via .users dict) - tests/unit/domain/: Email and User entity tests - tests/unit/application/: 4 branches of CreateUserUseCase - tests/unit/presentation/test_error_mapping.py: enforces exhaustive HTTP mapping - tests/e2e/api/: 1 happy + 1 critical-error per spec rule, dependency_overrides injecting fakes - All tests pass GIVEN/WHEN/THEN format check D2 hardened tooling: - ruff ruleset extended with UP, ANN, ARG, RET, SIM, PTH, T20, ERA, RUF, C90, D (Google), N, TID, PL — with curated ignore list (B008 for FastAPI Depends, TC001/2/3 too aggressive, D103 keep, D107 ignore on __init__) - per-file-ignores for tests, alembic versions, scripts - Generated project's .pre-commit-config.yaml with all custom lints + ruff + commitizen + gitleaks + pre-push pytest -m unit - no_naive_datetime refined to match only datetime.<method> (no false positive on clock.now()) - no_float_in_domain scoped to domain/ to allow legitimate float in RandomSource.next_float() Verified end-to-end on baked project: cookiecutter -> uv sync -> ruff check -> ruff format --check -> all 4 custom lints -> pytest unit + e2e (13 tests pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Containerisation (Pattern 1):
- Dockerfile (root): 2-stage prod-only build (builder copies uv + installs
deps, runtime slim image with non-root app:1001 user, tini PID1)
- .devcontainer/Dockerfile: extends mcr.microsoft.com/devcontainers/python
with uv, leverages MS image's vscode user / git / sudo / common-utils
- .devcontainer/docker-compose.yml: app + postgres:17 + jaegertracing +
otel-collector with healthchecks, named volume for PG persistence
- .devcontainer/devcontainer.json: remoteUser vscode, updateRemoteUserUID
for Linux UID/GID alignment, common-utils feature for Codespaces, mounts
~/.gitconfig + ~/.ssh read-only, postCreate runs uv sync + pre-commit
install, postStart runs alembic upgrade
- .devcontainer/otel-collector.yaml: minimal local OTLP collector pipe to
Jaeger
- .dockerignore: excludes tests/docs/helm/.devcontainer from prod image
Helm chart helm/{{ project_slug }}:
- Chart.yaml + values.yaml + values.schema.json + values-{dev,staging,prod}.yaml
- templates/: deployment (probes liveness/readiness/startup, securityContext
non-root + readOnlyRootFilesystem + dropped caps), service, ingress, hpa,
pdb, serviceaccount, configmap, secret, servicemonitor (opt-in via
metrics.enabled), migration-job (Helm pre-install/pre-upgrade hook running
alembic upgrade head, weight -5), tests/test-connection
- Cookiecutter Jinja and Helm Go templates coexist via {% raw %} wrapping
on every helm/templates file
Verified: helm lint passes, helm template renders both default and
values-prod overlays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI workflows in {{ cookiecutter.project_slug }}/.github/workflows/:
- ci-quality.yml: parallel jobs (ruff + ty + custom lints; pytest unit;
pytest integration; pytest e2e)
- ci-security.yml: Bandit (high severity, high confidence) + pip-audit +
Gitleaks
- ci-mutation.yml: mutmut on PRs touching src/tests + Monday cron, with
inline 90% mutation score floor enforcement
- ci-helm.yml: helm lint + helm template (default + prod) piped to
kubeconform + polaris audit
- ci-conventional-commits.yml: regex-based validation of all PR commits
- release.yml: tag-triggered build + GHCR push + GitHub release with
changelog
- dependabot.yml: weekly bumps for github-actions, pip, docker
- PULL_REQUEST_TEMPLATE.md with architecture self-check + AI assistance
disclosure section
Claude Code integration in {{ cookiecutter.project_slug }}/.claude/:
- settings.json: permissions deny on force-push / no-verify / .env reads /
rm -rf, ask on alembic downgrade / helm install / kubectl delete; hooks
on PreToolUse(git commit/push), PostToolUse(Edit/Write), UserPromptSubmit
- hooks/pre-commit-gate.sh: runs scripts/checks/quality.sh, exit 2 on fail
- hooks/pre-push-gate.sh: runs pytest unit+integration, exit 2 on fail
- hooks/audit-edit.sh: PostToolUse observer flagging domain purity, naive
datetime, test naming, and missing DomainError mapping (warn-only;
pre-commit and CI provide the hard block)
- hooks/inject-context.sh: UserPromptSubmit detects intent keywords and
injects skill recommendations as additionalContext
14 skills shipped (one folder per skill with SKILL.md frontmatter):
- onboarding-soma, clean-architecture-layers, writing-domain-code,
tdd-workflow, adding-a-use-case, adding-a-domain-exception, writing-tests,
writing-a-fake, using-the-di-container, database-and-migrations,
observability-patterns, naming-conventions, writing-a-helm-change,
reviewing-a-pr-soma-style
Verified end-to-end: bake, install, ruff, format, custom lints, pytest
(unit + e2e, 13 tests), helm lint, helm template render, template bake
tests (3/3 pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated project documentation: - docs/architecture.md: layer overview with ASCII diagram, dependency rule table, folder layout, tests pyramid, four-level guardrail summary - docs/adr/0001-clean-architecture.md: structural decision rationale - docs/adr/0002-tdd-and-mutation-testing.md: red-green-refactor + mutmut ≥90% policy - docs/adr/0003-fakes-only-in-unit-tests.md: ban Mock at unit level, testcontainers at integration, anti-duplication rule at e2e Template repo CI: - .github/workflows/ci-template.yml: unit job (3 combo bakes + ruff hooks) and integration job (full quality run on a baked project) - .github/workflows/ci-conventional-commits.yml: commit-message validator on the template repo itself - Removed obsolete ci-quality.yml, ci-security.yml, soma-quality.yml that targeted the old src/ tree pre-cookiecutter migration tests/test_template_bake.py extended: - 6 unit-marker tests: layout assertions, pyproject correctness, three combo bakes (postgres+helm+otel, sqlite+no-helm+otel, sqlite+no-helm+no-otel), database=none aborts - 1 integration-marker test: full quality pipeline on a baked project (uv sync + ruff + custom lints + pytest unit) Verified end-to-end on a fresh bake: all 6 unit bake tests, all generated-project tests (13/13), helm lint, full quality.sh aggregator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base classes (PEP 695 generic syntax): - domain/entities/base.py: Entity[TId] — identity-based __eq__/__hash__/__repr__, removes ~10 lines of boilerplate per entity - domain/value_objects/base.py: ValueObject — marker with __slots__ = (), preserves slots in subclasses using @DataClass(frozen=True, slots=True) - application/use_cases/base.py: UseCase[TInput, TOutput] — abstract contract with a single async execute(input) entry point - application/repositories/base.py: Repository[TEntity, TId] — abstract add + find_by_id; subclasses add domain-specific finders only DomainError polish: - code and default_message are now ClassVar (clearer subclass override semantics) - default_message replaces message at the class level — no shadowing of the per-instance message attribute - Subclasses (UserAlreadyExistsError, UserNotFoundError, InvalidEmailError) updated to use default_message Use-case observability via wrapper (no application/ imports of structlog or OTel): - infrastructure/container.py exposes _instrumented[U: UseCase[Any, Any]] - Wraps execute(): @Traced(span_name) opens an OTel span (no-op when otel disabled), DomainError raises log at WARNING with code + context, Exception raises log at ERROR with full stack trace, all re-raised - build_create_user_use_case applies _instrumented(span_name="create_user") - Application layer remains free of observability imports — fulfils the "no log in application/" rule with zero ergonomic cost User feature refactored to use the bases: - User(Entity[UserId]) - Email(ValueObject), UserId(ValueObject) - UserRepository(Repository[User, UserId]) — only declares find_by_email - CreateUserUseCase(UseCase[CreateUserInput, CreateUserOutput]) Skills updated to teach the new bases: - writing-domain-code: examples now inherit from Entity[TId] / ValueObject - adding-a-use-case: tables map each artifact to its base class; explains the _instrumented wrapper as the cross-cutting layer - using-the-di-container: documents the build_*_use_case + _instrumented pattern Verified: bake, ruff, all 4 custom lints, 13 tests pass on the baked project; 6/6 template bake tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
plus migration-drift CI
Initial Alembic migration:
- alembic/versions/0001_init_users.py creating the users table with
unique-indexed email and a non-empty downgrade
- alembic/env.py loads .env at module import so 'uv run alembic *' works
without the user manually exporting DB_URL
- post_gen_project.py runs 'alembic upgrade head' best-effort after bake
(creates local.db on sqlite default; tolerates a missing DB on postgres)
- aiosqlite added to runtime deps so sqlite default and tests work
Health probe split — /health/{live,ready,startup}:
- new presentation/api/health.py router
- /live: process responsive, no DB check (failure = pod restart)
- /ready: SELECT 1 against the request session (failure = LB removal)
- /startup: same DB check, looser K8s timeouts during boot
- old inline /health endpoint removed; /health/{live,ready,startup} wired
in app.py
- Helm values.yaml + helm test now point at the split endpoints
Reusable conftest fixtures:
- frozen_clock, sequential_ids, seeded_random — primitive deterministic doubles
- user_repository — InMemoryUserRepository ready to be inspected as a Spy
- create_user_use_case — composed from the above three
- StubAsyncSession — minimal AsyncSession shape for /health/ready tests
- app + client — FastAPI with dependency_overrides[get_create_user_use_case]
+ dependency_overrides[get_session] pre-set; client skips the FastAPI
lifespan (overrides bypass it)
- Existing unit and e2e tests refactored to use the fixtures (line count
per test cut roughly in half)
Health endpoint coverage: 4 new e2e tests covering /live, /ready (200 and
503 paths), /startup. Total tests: 13 → 17.
Migration-drift CI workflow (ci-migration-drift.yml):
- Spawns a Postgres 17 service and applies existing migrations
- Runs 'alembic revision --autogenerate -m drift_check'
- Fails the PR if the generated revision contains any 'op.*' calls,
proving that an ORM model was modified without a committed migration
- Path-filtered to persistence/, alembic/ and pyproject.toml
Other small fixes flushed out by Tier 1:
- LoggingInstrumentor wrapped in idempotent guard so create_app can be
called multiple times in the same process (test fixtures) without
double-instrument warnings
- test_naming script's _has_marker accepts '# GIVEN: <inline note>' so
fixture-driven tests can carry the marker without a body block
Verified: bake (sqlite) → uv sync → alembic head = 0001_init_users
→ ruff strict + 4 custom lints + ruff format → 17 tests pass → helm lint
clean → template bake tests 6/6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HTTP middleware stack (presentation/api/middleware/): - RequestIdMiddleware: accepts upstream X-Request-ID or generates UUID4 hex, binds it to the structlog contextvars so every log line in the request scope carries it, echoes it on the response. End-to-end correlation. - AccessLogMiddleware: emits one structured 'http.request' event per request with method/path/status/duration_ms/client. /health/* paths are silenced to avoid log noise. - BodySizeLimitMiddleware: rejects requests whose Content-Length exceeds request_max_body_bytes (default 1 MiB) with a 413 PAYLOAD_TOO_LARGE. - RequestTimeoutMiddleware: caps each request at request_timeout_seconds (default 30s) via asyncio.wait_for; returns 504 REQUEST_TIMEOUT and cancels the handler coroutine on overrun. Wired in app.py outermost-to-innermost: RequestId -> AccessLog -> BodySizeLimit -> RequestTimeout -> CORS -> SecurityHeaders -> handler. Two new env-controllable settings: request_max_body_bytes, request_timeout_seconds. 3 new e2e tests covering RequestId echo (generated and upstream-supplied) and the 413 oversized-payload path. Total tests: 17 -> 20. Graceful shutdown alignment: - Dockerfile CMD now passes --timeout-graceful-shutdown 30 to uvicorn (drains in-flight requests after SIGTERM). - Helm values.yaml exposes terminationGracePeriodSeconds: 35 (strictly greater than 30 so K8s never SIGKILLs before drain completes); rendered on the Deployment spec; covered in the JSON schema. CI image supply chain: - ci-image-scan.yml on PR: builds the runtime image (no push), runs Trivy fail-on-HIGH-or-CRITICAL with ignore-unfixed, generates a CycloneDX SBOM artefact via Syft. - release.yml extended on tag: rebuilds, scans, pushes, signs the image with cosign keyless (Sigstore + GitHub OIDC, id-token: write permission), attaches the CycloneDX SBOM as a Sigstore attestation, and ships sbom.cdx.json as a GitHub Release asset. docs/architecture.md grew a 'Production posture' section covering the middleware order, graceful shutdown alignment, and the cosign verify command consumers can run. Verified: bake (sqlite) -> alembic head -> ruff strict + 4 custom lints + ruff format -> 20 tests pass -> helm template renders terminationGracePeriodSeconds=35 -> template bake tests 6/6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
justfile at the project root becomes the single source of truth for every
command run by humans, Claude Code hooks, and CI. Recipes:
- install / hooks: setup
- dev: uvicorn auto-reload
- test / test-all / test-integration / test-e2e: pytest variants
- mutate / mutate-results: mutmut on domain + application
- lint / fmt / typecheck: quality gates
- migrate / migration MSG / migrate-down: alembic
- docker-build / helm-lint / helm-render ENV=prod: container + chart
- ci-quality / ci-mutation: aggregator targets matching CI
Cookiecutter / just template syntax conflict resolved with {% raw %}
blocks: 'message' and 'env' stay as just-runtime placeholders; the
package_name / project_slug references are baked at cookiecutter render.
Devcontainer install:
- features += ghcr.io/guiyomh/features/just:0
- postCreateCommand simplified to 'just install && just hooks'
- postStartCommand simplified to 'just migrate || true'
Skills + README updated to point to the just recipes:
- onboarding-soma: 'Five commands you need' now lists just install / dev /
test / test-all / mutate
- tdd-workflow: 'Mutate' step uses 'just mutate'
- adding-a-use-case: '10. Verify' uses 'just lint && just test-all && just mutate'
- database-and-migrations: alembic commands replaced with 'just migrate' /
'just migration "..."' / 'just migrate-down'
- writing-a-helm-change: helm validation uses 'just helm-lint' / 'just
helm-render <env>'
- reviewing-a-pr-soma-style: useful one-liners use just recipes
- README: same five-command landing block
Claude hooks (pre-commit-gate.sh, pre-push-gate.sh) deliberately keep
calling the underlying scripts directly — they must still run when just
is unavailable (Claude in non-devcontainer shells).
Verified: bake (sqlite) -> 20 tests pass -> helm lint clean -> bake tests
6/6 -> rendered justfile keeps {{ message }} and {{ env }} as just
placeholders while substituting cookiecutter vars literally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New cookiecutter variable frontend_sdk: ['none', 'typescript'] - 'none' (default): no SDK artefacts shipped, justfile has no openapi recipe - 'typescript': ships scripts/export_openapi.py, the sdk-typescript.yml workflow, and a 'just openapi' recipe scripts/export_openapi.py: - Sets DB_URL to in-memory sqlite before importing the app so Pydantic Settings validation succeeds without a real DB - Calls create_app().openapi() and writes openapi.json with sorted keys - Verified working on a baked project: produces a valid OpenAPI 3.x schema with the /v1/users and /health/* endpoints .github/workflows/sdk-typescript.yml: - Triggered on push to main when presentation/, application/dtos/, the export script or the workflow itself change (plus manual dispatch) - Runs the export, sets up Java + openapi-generator, generates a typescript-fetch client into ./sdk-typescript/, uploads as artefact - Two opt-in extension points commented inline: push to a separate SDK repo, publish to npm post_gen_project.py removes the SDK files when frontend_sdk == 'none' (same pattern already used for helm/, presentation/cli/). justfile gates the 'openapi' recipe behind a Jinja conditional so 'just' never advertises a recipe whose script is absent. README mentions the SDK pipeline only when frontend_sdk == 'typescript'. Bake tests now cover 4 combos including ts-sdk; the test asserts both the presence (when typescript) and the absence (when none) of the SDK artefacts. 6 -> 7 template bake tests pass. Verified on both bake variants: - frontend_sdk=none: no scripts/export_openapi.py, no sdk-typescript.yml, ruff strict + 20 generated-project tests pass - frontend_sdk=typescript: both files present, 'uv run python scripts/export_openapi.py' produces a valid openapi.json, ruff strict + 20 generated-project tests still pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#10 — Mermaid diagrams in docs/architecture.md: - Layers diagram: Mermaid flowchart with subgraphs (outer/inner) and colored nodes per layer; arrows show the inward-only import direction. - Sequence diagram: full request flow for POST /v1/users tracing the hand-off from client through middleware, router, DI, use case, repo, Postgres, and the response path. #11 — Transaction boundary at the request scope (ADR 0004): - get_session now opens 'async with factory() as session, session.begin()': one HTTP request = one transaction. Successful return commits, any raised exception (DomainError or otherwise) rolls back. This fixes a latent bug where the previous code path opened a session without begin(), so writes were silently rolled back at session close. - Repositories continue to flush() (to surface IntegrityError early) but never commit() — the boundary owns it. - ADR 0004 documents the choice, the alternatives considered (commit in repo / commit in use case / boundary), and the consequences (atomicity, read-only cost, savepoint escape hatch via session.begin_nested()). #12 — Outbox pattern (ADR 0005): - New OutboxEventModel with event_name + payload (JSON) + created_at + published_at, indexes on event_name / created_at / published_at. - Migration 0002_init_outbox creates the table with non-empty downgrade. - infrastructure/outbox.py ships SqlOutboxEventBus (writes events to the outbox in the request transaction) and NullEventBus (default for projects not yet emitting events). - Container exposes build_event_bus(session) — defaults to the SQL binding so use cases that take an EventBus get atomic publication out of the box. - infrastructure/jobs/outbox_relay.py is a runnable polling-loop scaffold with a stubbed _publish_event that logs; the comment block lists drop-in requirements for the real broker integration (at-least-once, consumer idempotency, backoff + alerting). Drains BATCH_SIZE rows per tick, sleeps 1s when nothing pending, traps SIGTERM/SIGINT. - ADR 0005 explains the dual-write problem the outbox solves, the alternative (transactional broker) we rejected, and consequences (polling latency, retention policy left to projects). - New skill 'transactions-and-events' covers the request-boundary rule (no commit/rollback in repos or use cases) AND how to publish events via the outbox. Verified: bake (sqlite) -> alembic head = 0002_init_outbox -> ruff strict + 4 custom lints + format -> 20 generated-project tests -> outbox & relay modules import cleanly -> helm lint clean -> 7/7 template bake tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LICENSE (one file, conditional content via Jinja): - 'proprietary' (default): all-rights-reserved notice with author_name and 2026 year; standard disclaimer. - 'MIT': full MIT text. - 'Apache-2.0': Apache 2.0 short form pointing to apache.org for the full text (the full ~11K Apache text is omitted to keep the file reviewable; consumers can paste the canonical text if their legal team requires it inline). .github/CODEOWNERS: - Default '*' route + per-area overrides (domain/, application/, alembic/, persistence/, helm/, Dockerfile, .devcontainer/, .github/workflows/, SECURITY.md). - Each route uses a placeholder team handle (@your-team, @your-architects, @your-dba, @your-platform, @your-security) that the project edits before merging the first PR. SECURITY.md: - Vulnerability disclosure policy (private email or GitHub PVR). - Coordinated disclosure timeline: 3-day acknowledge, 10-day assessment, 30-day fix target for high severity. - Supported versions matrix. - Out-of-scope clarifications. - Hardening checklist for downstream operators (cosign verify, pin tags, rotate secrets, keep securityContext defaults). Bake tests: - EXPECTED_TOP_FILES extended with LICENSE, SECURITY.md, .github/CODEOWNERS. - New parametric test verifies the LICENSE content matches the chosen license value across all three options. - Total template bake tests: 7 -> 10. Verified on a sqlite+MIT bake: LICENSE renders correctly, SECURITY.md references author_email, CODEOWNERS has all routes, full quality.sh + 20 generated-project tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conventional Commits enforcement consolidated to local hooks:
- commitizen pre-commit hook on commit-msg stage already validates every
human commit
- Claude Code permissions deny 'git commit --no-verify*' so AI cannot
bypass either
- The CI gate added cost on every PR for a class of error already caught
twice; cost of an escaped bad message = squash on merge
Removed:
- {{ project_slug }}/.github/workflows/ci-conventional-commits.yml
- (template repo) .github/workflows/ci-conventional-commits.yml
- README.md now states the intent: 'enforced locally, no CI gate'.
- reviewing-a-pr-soma-style skill checklist updated to mention
ci-migration-drift + ci-image-scan instead.
ci-mutation.yml: dropped the Monday 06:00 UTC cron trigger.
- The path-filtered PR trigger (src/**, tests/**, pyproject.toml)
already fires on every change that could move the score, including
Dependabot-bumped dependencies (which touch pyproject.toml).
- The cron added a weekly notification with no signal beyond what PRs
already provide. Removing it cuts Actions minutes and noise.
Verified: bake (sqlite) -> 7 workflows in generated project (was 9),
ci-mutation.yml triggers only on pull_request, full quality.sh + 20
generated-project tests pass, 10/10 template bake tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ty hardened to a hard CI gate (no more `|| true`):
- Repository.add and find_by_id changed to positional-only ('entity, /'
and 'id, /') so subclasses can rename the parameter without breaking
Liskov. Eliminates a whole class of false positives in derived repos.
- SqlAlchemyUserRepository.find_by_id and InMemoryUserRepository.find_by_id
renamed user_id -> id to align with Repository contract.
- BaseHTTPMiddleware subclasses (RequestTimeout, BodySizeLimit,
SecurityHeaders) now type 'app: ASGIApp' instead of the looser
'Callable[..., Awaitable[Response]]' that did not match Starlette's
signature.
- _instrumented in container.py annotated with 'ty: ignore[invalid-
assignment]' alongside the existing mypy 'method-assign' suppression
(rebinding the bound method on the instance is the wrapper's purpose).
- register_error_handlers' DomainError handler annotated with
'ty: ignore[invalid-argument-type]' (Starlette overload set does not
include async handlers returning JSONResponse; FastAPI accepts at
runtime).
- scripts/checks/quality.sh and ci-quality.yml now run 'uv run ty check
src/' as a hard step. ty errors block PRs.
Prometheus /metrics endpoint:
- prometheus-fastapi-instrumentator added to runtime deps.
- create_app wires Instrumentator(...).instrument(app).expose at
/metrics, excluded from the OpenAPI schema, with /metrics and
/health/* excluded from instrumentation to avoid feedback noise.
- AccessLogMiddleware silences /metrics alongside /health/*.
- New e2e test asserts /metrics returns the prometheus text format with
process and HTTP request metrics.
- docs/architecture.md grew a 'Prometheus metrics' subsection.
- Closes the inconsistency where the Helm chart shipped a ServiceMonitor
with no endpoint serving metrics.
New Claude skill 'adding-a-new-abstraction':
- Opens with a STOP-and-check table mapping each existing abstraction
(Clock, IdGenerator, RandomSource, EventBus, AuditLog, Repository) to
what it already covers, with explicit anti-patterns ('a TokenGenerator
next to IdGenerator', 'a UserActivityRecorder next to AuditLog').
- Documents when a new abstraction is genuinely justified (real external
dep + not a refinement + replaceable mechanism).
- Lists role stereotypes and their typical class names (Sender, Gateway,
Hasher, Reader/Writer, Cache, Scheduler, Source).
- Walks through the 8-step shipping checklist (abstract -> production
binding -> Fake -> container factory -> FastAPI Depends -> Settings ->
conftest fixture -> ADR if architectural).
- Wired into the project CLAUDE.md routing table BEFORE
'adding-a-domain-exception'.
Tests: 20 -> 21 (new test_metrics e2e). Template bake tests: 10/10 pass.
helm lint clean. ty strict on the baked project: 0 diagnostics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The User feature ships as real production code (src/, tests/, migration
0001_init_users) so that the generated project boots, has a working
endpoint, and demonstrates the full Clean Architecture stack on first
'just dev'. Projects whose domain has no notion of a user need a clean
deletion path; until now this was tribal knowledge.
The new README subsection 'Removing the reference feature' lists:
- 12 source files to git rm (entity, value objects, exceptions, repo,
use case, DTOs, ORM model, route, schema, deps wiring)
- 5 test files + 1 migration to git rm
- 4 in-place edits (conftest fixtures, ERROR_HTTP_MAPPING, container
factories, app.py router include) with the exact symbols to drop
- a sanity command pair ('just lint && just test-all')
- an explicit list of what stays and is reusable (base classes,
abstractions, middleware, health probes, conftest primitives)
Also fixed a stale README pointer that referenced
'examples/feature_user_signup/' — the feature lives in src/, not in
examples/, since the original spec parking decision §24.
Verified: bake (sqlite) -> README renders 'git rm src/my_soma_service/...'
correctly substituted -> 10/10 template bake tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration tests Conftest split (4 files, one per layer): - tests/conftest.py — primitives shared across all layers: frozen_clock, sequential_ids, seeded_random, user_repository, create_user_use_case. user_repository and create_user_use_case stay at the root because they are reused as both unit Fakes and as Spies behind the e2e dependency_overrides. - tests/unit/conftest.py — placeholder docstring; reserved for unit-only fixtures as the project grows. - tests/integration/conftest.py — testcontainers Postgres 17, async engine + schema once per session, pg_session fixture yielding a per-test transaction that rolls back on exit (mirrors the request-boundary semantics of ADR 0004). - tests/e2e/conftest.py — StubAsyncSession + stub_session, app fixture with dependency_overrides for get_create_user_use_case and get_session pre-wired to the root Fakes, client TestClient skipping the FastAPI lifespan. Side fix: test_health_endpoints.py imported StubAsyncSession from tests.conftest, now imports from tests.e2e.conftest after the split. Integration tests for SqlAlchemyUserRepository (5 tests, no parametrize, each covers a distinct edge case): - empty DB → add → find_by_id round-trips through the model ↔ entity mapping - duplicate email → IntegrityError translates to UserAlreadyExistsError (with the canonical 'USER_ALREADY_EXISTS' code) - find_by_email returns the persisted user - unknown email → None - unknown id → None These tests prove the contract that the unit-level InMemoryUserRepository Fake cannot: real flush() behaviour, real unique constraint, real mapping. They establish the canonical pattern projects copy when adding new repositories. README's 'Removing the reference feature' section grew the new test_user_repository.py file in the deletion list (5 → 6 tests dropped). Verified end-to-end: bake (sqlite) -> 21 unit+e2e tests pass -> 5 integration tests collected (deferred to Docker-CI) -> ruff strict + ty strict + 4 architectural lints + format clean -> 10/10 template bake tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The template's framing has consistently been Clean Architecture, not DDD. 'Aggregate' had nonetheless leaked into the docs, skills and source docstrings — a DDD-specific term that requires DDD vocabulary the template does not teach. Replaced with 'entity' (or 'feature' in the ADR 0001 case), which is the term we actually defined and use. 15 occurrences cleaned across 10 files: - docs/adr/0001-clean-architecture.md (User feature, not aggregate) - docs/adr/0005-outbox-pattern.md (4 occurrences in the dual-write problem statement and the consequences section) - src/<pkg>/application/repositories/__init__.py - src/<pkg>/application/repositories/base.py (Repository docstring) - src/<pkg>/application/repositories/user.py (UserRepository docstring) - src/<pkg>/infrastructure/outbox.py (2 occurrences) - src/<pkg>/infrastructure/persistence/models/outbox_event.py - .claude/skills/adding-a-new-abstraction/SKILL.md - .claude/skills/naming-conventions/SKILL.md - .claude/skills/transactions-and-events/SKILL.md For our reference feature (User entity with VOs but no child entities), 'entity' carries every bit of meaning 'aggregate' did. Projects that later need true aggregate roots (Order with OrderLines) can introduce the term locally if it helps their team — the template stops shipping unexplained DDD jargon. Verified: bake (sqlite) -> ruff strict + ty strict + 4 architectural lints + format clean -> 21 unit+e2e tests pass -> 10/10 template bake tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l Docker-absent skip
Three real-world breakages caught by trying to actually bring the
devcontainer up on a freshly-baked project:
1. Image tags that did not exist on Docker Hub:
- jaegertracing/all-in-one:1.62 → 1.75.0 (the '1.62' shorthand
never existed; the closest was '1.62.0', and 1.75.0 is the latest
stable v1.x line — Jaeger v2 dropped the all-in-one image).
- otel/opentelemetry-collector-contrib:0.115.0 → 0.140.0 (the
'.0' patch was never published; 0.140.0 is current).
2. just was declared as a devcontainer Feature
(ghcr.io/guiyomh/features/just:0). Features are only applied by the
devcontainer CLI (VSCode 'Reopen in Container', Codespaces). Plain
'docker compose up' on the .devcontainer compose file produced an
image without just — and 'just install' is precisely what
postCreateCommand calls. Baked just directly into the devcontainer
Dockerfile via the official just install script. Removed the now-
redundant Features entry from devcontainer.json. Both flows
(devcontainer CLI + plain compose) now produce a fully usable image.
3. tests/integration/conftest.py::pg_url errored noisily when the Docker
daemon was unreachable (typical inside a devcontainer that does not
mount the host docker.sock). Wrapped both PostgresContainer
construction AND start() in a try/except that calls pytest.skip with
a clear message. Inside the devcontainer 'just test-integration' now
reports '5 skipped' with the reason; on the host or in CI Docker is
reachable and the tests run normally.
Also: gitignored /toto/ — the locally baked sandbox the dev used to
shake all this out.
Verified end-to-end on a baked 'toto' project:
- docker compose build / up brings 4 healthy containers (app, postgres,
jaeger, otel-collector)
- 'just install' resolves 119 packages
- 'just test' runs the 11 unit tests (all green)
- 'just migrate' applies 0001_init_users + 0002_init_outbox against the
live Postgres
- 'just test-all' reports 21 passed + 5 skipped (integration, no Docker
socket inside the devcontainer)
- POST /v1/users returns 201 with the new user persisted in Postgres
- /health/live and /health/ready respond {status: live} / {status: ready}
Template bake tests: 10/10 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… + session-scoped asyncio loop
Two bugs prevented 'just test-integration' from running inside the
devcontainer; both are now fixed:
1. Docker socket access: testcontainers spawns ephemeral containers via
/var/run/docker.sock. Mounting the socket alone is not enough — the
vscode user lacks the right group membership and gets PermissionError.
Added the official Microsoft Feature
ghcr.io/devcontainers/features/docker-outside-of-docker:1 which:
- installs the docker CLI inside the devcontainer
- reconciles the vscode user with the host docker.sock GID at
postCreate so the SDK can connect.
docker-compose.yml now mounts /var/run/docker.sock and adds
host.docker.internal:host-gateway plus TESTCONTAINERS_HOST_OVERRIDE
so testcontainers' spawned PG (random host port) is reachable from
inside the devcontainer.
The Feature only runs via the devcontainer CLI (VSCode 'Reopen in
Container', Codespaces, 'devcontainer up'). Plain 'docker compose up'
does not apply Features; the pg_url fixture skips integration tests
cleanly in that path.
SECURITY: mounting the host socket gives the devcontainer
root-equivalent access to the host. Standard Docker-out-of-Docker
tradeoff, documented inline.
2. asyncio event loop scope: pg_engine is session-scoped, but
pytest-asyncio 0.25+ creates a fresh function-scoped event loop per
test. The engine bound to test 1's loop became invalid for test 2 —
"Future attached to a different loop". Set
asyncio_default_fixture_loop_scope = "session" and
asyncio_default_test_loop_scope = "session" in pyproject so all
async fixtures and tests share one event loop. The test pyramid
gains nothing from per-test loops in our setup.
Verified end-to-end on a baked 'toto' project after these fixes:
- 'docker compose up' brings 4 healthy containers
- 'just test' (unit only): 11 pass
- 'just test-all' (with the Feature applied via devcontainer CLI):
21 pass + 5 integration pass = 26/26
Template bake tests: 10/10 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Feature defaults to installing Moby (the upstream open-source Docker
engine fork), but the Microsoft devcontainers/python:3.13 base ships a
Debian release for which the Moby package is not published. The build
errors with a clear hint:
(!) To continue, either set the feature option '"moby": false' or use
a different base image (for example: 'debian:bookworm' or
'ubuntu-24.04').
Setting moby: false makes the Feature install the upstream docker-ce-cli
from Docker's official APT repo, which works on every Debian release the
MS image targets. The behaviour we actually need (CLI talks to the host
docker.sock, vscode user is added to the right group at postCreate) is
unchanged.
Verified end-to-end on a fresh bake of /toto (npx @devcontainers/cli up):
- build succeeds (postCreate + postStart run)
- vscode user lands in groups vscode + docker(124) + nvm + pipx
- docker CLI v29.4.3 inside the container
- 'just test-all' reports 26/26 pass (21 unit+e2e + 5 integration with
testcontainers spawning ephemeral PG via the host docker.sock)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The integration test for the duplicate-email path triggered: SAWarning: transaction already deassociated from connection Cause: when SqlAlchemyUserRepository.add() flushes a duplicate row, asyncpg surfaces an IntegrityError that bubbles up to SQLAlchemy, which auto-rolls back the session's transaction. The pg_session fixture had its own outer transaction open on the same connection; the teardown rollback then ran against an already-rolled-back transaction, producing the warning. Fix: pass join_transaction_mode='create_savepoint' to the async_sessionmaker. The session now opens a SAVEPOINT for its writes inside the fixture's outer transaction. An IntegrityError rolls back the savepoint only; the outer transaction stays alive and the teardown rollback works cleanly. Verified on a fresh devcontainer rebuild via @devcontainers/cli: 'just test-all' reports 26 passed in 5.24s — no warnings emitted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… merged with writing-a-fake
Three skills reviewed and refined; one merged.
onboarding-soma:
- Drop the stale 'examples/feature_user_signup/' pointer (the directory
does not exist; the User feature lives in src/, per parking decision
§24 in the design spec).
- Rewrite the reference-feature file list as a categorised table
(domain / application / infrastructure / presentation / tests) and
include the previously-missed pieces: dependencies/users.py,
dtos/user.py, container factories, the integration test, the fake.
- Replace the in-skill 'then read these skills' list (which duplicated
the routing table from CLAUDE.md and would drift) with a 'Where to
look next' section pointing to CLAUDE.md as the routing source of
truth, plus docs/architecture.md and docs/adr/.
- Mention the 'Removing the reference feature' README section for
projects whose domain has no notion of a user.
tdd-workflow:
- Add a worked example (Email lowercase) showing red → green → refactor
→ mutate concretely. The skill described the loop without ever
showing it, which is exactly what TDD newcomers need.
- 'Read the surviving mutant' as a sub-procedure under step 4 (1. read
the mutant, 2. write a failing test that would kill it, 3. re-run).
- Replace 'uv run pytest -m unit' with 'just test'.
- Delegate the GIVEN/WHEN/THEN format details to writing-tests; tdd-
workflow is now about the loop, not the test format.
- Hard rule added: 'bug fixes follow the same loop' — a bug = a missing
test.
writing-tests <- writing-a-fake (merged):
- writing-a-fake had a 90% trigger overlap with writing-tests (anyone
writing a unit test that needs a deterministic double).
- Merged into a single 'source of truth for everything under tests/'
skill, with the Fake content as a sub-section.
- Added the rationale for Fakes-only ('a Mock asserts which method got
called; a Fake asserts what observable behaviour happened').
- Document per-layer conftests (tests/conftest.py + per-dir conftests).
- Replace 'uv run mutmut run --paths-to-mutate ...' with 'just mutate'.
- 5th mistake to avoid for new Fakes: 'register as conftest fixture if
more than one test uses it'.
CLAUDE.md routing table updated: 'tests/fakes/' merged into the
'tdd-workflow then writing-tests' row.
No code changes; documentation pass only. Bake tests still 10/10 pass
since the skill files are not part of the bake assertion set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e + naming-conventions
clean-architecture-layers:
- Add a "What each layer is for" responsibility column (was just imports).
- Expand the "Where does X go?" table by 10 rows previously missing:
RandomSource / EventBus / AuditLog abstractions, container.py, the
outbox bundle, infrastructure/jobs/, the FastAPI app factory,
health.py, ERROR_HTTP_MAPPING, alembic versions/.
- Mention the base classes (Entity[TId], Repository[TEntity, TId],
UseCase[TInput, TOutput]) in the row for each kind.
- Replace the leftover "Aggregate" wording with "Entity with identity".
- New "Where does X NOT go?" anti-pattern table — 10 common mistakes
with the layer they actually belong to.
- "Enforcement" now lists the three custom AST lints + ty strict.
writing-domain-code:
- Hard rules: gloss the FIRST.R property inline ("same input must give
same output"), scope the no-float lint to domain/, explain that
raising typed exceptions replaces the no-log rule.
- New "What domain/ may import" positive section listing the stdlib
surface + the three base classes (Entity, ValueObject, DomainError).
Was previously only "no third-party" without saying what is allowed.
- Add UserId as the canonical small VO example shipped in the template.
- Mark Money / Order / InsufficientBalanceError as hypothetical
illustrations (they are not in the template) so readers do not search
for them. Point at the real User entity.
- Two-line gloss of frozen=True / slots=True semantics.
- New "Domain services" section: where to put operations that span
multiple entities, with a "use sparingly" note.
naming-conventions:
- New "Verb cheat sheet" table — one row per concept (get, find, load,
add, register, create, delete, archive, find_by, exists). Makes the
abstract "one word per concept" rule actionable; fights the recurring
get/fetch/retrieve/lookup confusion.
- Universal rules grow a "Names should not lie" item with a concrete
example.
- New "Variables and function arguments" section: email vs
email_address, plurals for collections, abstract for params /
concrete for locals.
- New "Constants" section: module-level vs ClassVar, the ruff PLR2004
trigger for magic numbers.
- Banned generic names: explicit that the real role hides under each —
pick a stereotype.
- Concretes table: add EventBus / SqlOutboxEventBus.
- Tests subsection delegates the GIVEN/WHEN/THEN body format to
writing-tests instead of repeating it.
CLAUDE.md follow-up: two stale 'examples/feature_user_signup/' pointers
fixed (project context + Pointers footer). The reference feature lives
in src/ and tests/, per parking decision §24 of the design spec.
No code changes; documentation pass only. Bake tests still 10/10 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ adding-a-new-abstraction
adding-a-use-case:
- Every step table grew a 'User reference' column pointing at the actual
shipped file to copy. Removes the abstract '<noun>' placeholder
feeling — the User feature IS the worked example.
- Drop stale 'examples/feature_user_signup/' opener (the directory does
not exist; reference feature lives in src/).
- Replace 'uv run pytest -m unit' / 'uv run alembic revision' with
'just test' / 'just migration "..."' / 'just test-integration'.
- New step 6: integration tests for the new repository (was completely
missing). Lists the canonical edge cases (happy path, integrity →
DomainError, missing → None).
- Step 2 cross-references adding-a-new-abstraction BEFORE the user
declares a new collaborator class.
- Step 3 names DTOs explicitly: '<Action><Noun>Input' / '<Action><Noun>Output'.
- Step 7 container snippet shows the full _instrumented(span_name=...)
wrapping (was abbreviated).
- Step 8 wiring: 'after app.include_router(health_router)' for clarity.
- Step 9: explicit rule 'different HTTP per endpoint = different error
type', no per-route override of ERROR_HTTP_MAPPING.
adding-a-domain-exception:
- Frontmatter notes the audit-edit Claude hook auto-flags any new
'...Error' class under domain/exceptions/.
- Step 2 fix: 'message =' → 'default_message =' (the polish on
DomainError made these ClassVars; the skill still showed the old
attribute name).
- New 'Optional: rich context for logs' subsection with __init__(**context)
example and link to _instrumented (the wrapper picks it up
automatically).
- Step 3: hard rule 'different HTTP for the same exception in different
endpoints = wrong, extract a more precise type'.
- Step 4: full TDD red-then-green example (the test that raises, the
production code that satisfies it, then 'just mutate').
- Step 5 reworded: clarifies WHEN the e2e suite must change (only when
the new error is the *primary* error path of an existing endpoint).
- HTTP cheat sheet expanded from 8 to 12 codes (added 400, 410, 423,
503).
- Cross-reference naming-conventions for the '<Subject><Verb>Error'
pattern.
adding-a-new-abstraction:
- Justification criterion 3 sharpened: 'the use case would survive a
swap' with concrete examples (SendGrid → Mailgun, Redis → DynamoDB).
- Code snippets for steps 1-5 trimmed: kept the structural skeleton,
dropped boilerplate.
- New step 8: integration test for the production binding when it does
I/O (testcontainers when self-hostable, mock only for SaaS sandboxes).
- Step 9 (ADR) gets concrete decision examples (outbox vs direct broker
→ ADR; argon2 vs bcrypt → no ADR).
- Step 7 conftest registration: explicit threshold ('if more than one
test will use the Fake').
- Self-check grew from 6 to 9 items: added the integration test and
the conditional ADR.
No code changes; documentation pass. Bake tests still 10/10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nd-migrations / observability / helm using-the-di-container: - New 'Factories shipped' table — 7 build_* helpers + 6 get_* Depends. Stops the AI from re-declaring what already exists. - 'Test override' rewritten around the e2e conftest 'app' fixture (most tests don't override; when they do, override on app.dependency_overrides). - Concrete worked example: simulating a DB outage on /health/ready. - New 'Anti-patterns' table — 6 mistakes with their fix (direct imports of infrastructure from presentation, lambdas in routes, mutating fixtures, etc.). transactions-and-events: - 'get_session' real code snippet (async with factory() as session, session.begin()) — was just prose. - '_instrumented cooperation' subsection explicit: WARNING log on DomainError before the boundary rollback fires. - 'Adding events to a use case' makes clear CreateUserUseCase shipped does not currently emit events; shows the full chain to wire one (factory + dependency + chain). - New 'Event naming convention' (<aggregate>.<verb_past>, dotted lowercase) with anti-patterns. - New 'Producer-side idempotency' subsection: why the outbox is naturally idempotent (same transaction as the entity write; relay marks published_at only after broker ACK; consumers dedupe on row id). - Cross-reference pg_session fixture as a worked example of join_transaction_mode='create_savepoint'. - Relay worker: BATCH_SIZE called out, 'no backoff by default — add before prod' note. database-and-migrations: - Conventions list expanded: UUID PKs via sa.Uuid(), TZ-aware DateTime(timezone=True), explicit nullable=False, unique+index when queried by value, String(N) over Text. - Autogenerate review now lists the 'NOT NULL added to non-empty table' case with a concrete server_default snippet and the add-nullable → backfill → set NOT NULL three-migration pattern. - ci-migration-drift.yml mentioned — explains the CI gate that catches 'I changed the model and forgot the revision'. - New 'Adding a column to an existing table' section (was missing). - New 'Testing the new repository' section pointing at the shipped test_user_repository.py and the pg_session fixture. observability-patterns: - Reorganised around four numbered surfaces: Logs, Tracing, Metrics, Request correlation. - '@Traced on a use case class' explicitly called out as wrong: application/ must not import infrastructure/observability/; the container's _instrumented wrapper does the job. @Traced is still fine inside infrastructure/ on a slow helper. - New 'LOG_FORMAT switch' table (console vs json). - New 'Metrics' section: /metrics endpoint via prometheus-fastapi-instrumentator, ServiceMonitor gate, infrastructure/observability/metrics.py as the home for custom counters / histograms. - New 'Request correlation' section: RequestIdMiddleware (echo or generate X-Request-ID, bind to structlog contextvars), AccessLogMiddleware (one 'http.request' event per request with status / duration_ms / client). - Anti-patterns table grew from 4 bullets to 7 rows. writing-a-helm-change: - New 'What ships in the chart' tree annotated with what each template does and what gates it (autoscaling.enabled, ingress.enabled, metrics.enabled, …). - Edit checklist #4 spells out the terminationGracePeriodSeconds / uvicorn --timeout-graceful-shutdown alignment (30s + 5s buffer). - 'Common patterns' grew from 3 to 5 — added 'enable metrics scraping' (ServiceMonitor flip) and 'tune per env' (overlay vs base) patterns. - 'Things to avoid' from 4 to 6 — added 'shrink termination grace period below 30' and 'add value without schema entry' (silent override risk). No code changes; documentation pass only. Bake tests still 10/10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final group of the review pass. reviewing-a-pr-soma-style: - New 'Tone' section: precise + cited rule + MUST/SHOULD/nit + propose the fix + confirm what works. Sets expectations for both human and Claude reviewers so the comment loop stays sustainable. - Architecture section grew: no HTTP types in application/, no time.time(), explicit AuditLog as the alternative to logging in application/. - New 'Base classes' section (7 checks): Entity[TId], ValueObject + frozen dataclass, Repository[TEntity, TId], UseCase[TInput,TOutput], DomainError with code+default_message as ClassVar, ERROR_HTTP_MAPPING entry, _instrumented wrapping in the container factory. - New 'Abstractions' section (3 checks): the adding-a-new-abstraction table was walked; role stereotype rather than Port; abstract + binding + Fake + conftest fixture all present. - New 'Events' section (4 checks): <aggregate>.<verb_past> naming, EventBus constructor param, container + Depends chain, flat payload. - Migrations: explicit check for NOT-NULL-on-non-empty with server_default or three-step split. - Helm: explicit terminationGracePeriodSeconds ≥ uvicorn --timeout-graceful-shutdown check (the alignment we discovered the hard way during the devcontainer fixes). - CI green list mentions the conditional workflows (ci-migration-drift, sdk-typescript) so reviewers know which ones are expected per change. - Extra one-liner: zoom diff on src/*/domain/** when the PR touches it. Skills review complete: - 16 skills audited and refined. - writing-a-fake merged into writing-tests (single source of truth for everything under tests/). - onboarding-soma + CLAUDE.md no longer reference the stale examples/feature_user_signup/ directory. - All 'uv run ...' commands now point at the equivalent 'just' recipe. - Cross-references between skills made explicit; CLAUDE.md is the single routing table. - Final skill count: 15. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreateUserUseCase now publishes user.created and the relay forwards each row to handlers registered via @handler at import time. No external broker is shipped — the swap point is handlers.dispatch, which becomes a real broker.publish call the day SOMA picks one. Adds: handlers.py (registry + dispatch + handler decorator), event_handlers.py (reference handle_user_created), InMemoryEventBus fake, four integration tests proving the outbox→handler roundtrip (happy, failing handler keeps row unpublished, empty batch, no replay on already-published rows), two extra unit tests on the use case (publishes on success, does not publish on duplicate email). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small additions to the ``adding-auth`` skill from the Phase 3 re-run of the TodoList walkthrough (2026-05-20): 1. **Exception codes table** — the skill listed the exception class names but not the ``code`` ClassVar that clients deserialize on. I guessed ``MISSING_TOKEN`` writing the e2e test ; the real value is ``AUTH_MISSING_TOKEN``. Added a small table mapping class → ``code`` → HTTP so future tests assert on the right value first try. 2. **``current_user.subject`` → domain identifier mapping** — a route that does ``UserId(UUID(current_user.subject))`` becomes a 500 if the IdP issues a non-UUID ``sub``. Documented the two clean options (trust the IdP contract, or wrap the cast in a try/except that raises ``InvalidTokenError``) so the next dev decides up front instead of shipping a latent 500. Also updated ``docs/validation/2026-05-20-todo-walkthrough.md``: - Status updated from "Phase 3 abandonnée" to "Phase 3 re-run with a narrower scope". - Added the executed scope (3 endpoints + 5 new tests + ownership rejection at 404). - Logged 3 frictions (exception codes naming, subject cast safety, DTO refactor mechanics) and 3 positives (FakeTokenVerifier ergonomic shape, HTTP mapping shipped, CurrentUser as immutable VO). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The README showed two usage modes — interactive prompts and a bare ``--no-input`` smoke-bake — but never the override syntax (``project_name="Foo" database=postgres`` positional args after the template path). Friction noted during recent walkthroughs : a user who wants to script a bake or test combinations has to dig into the cookiecutter docs to find the form. Two short additions: - **Generate a new project** gets a "Non-interactive (CI, scripts, repeat bakes)" subsection with the full override example and a hint about ``--config-file`` for larger configs. - **Develop on the template itself** gets a second snippet showing how to bake with custom values for manual inspection. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inent
The rule existed in ``tdd-workflow`` but it was buried in the middle of
the "GIVEN/WHEN/THEN strict" section at line 323, and the
"kill-the-obvious mutmut survivors" checklist I added recently showed
THREE asserts in the same test — directly contradicting the rule.
Two fixes:
1. **Fix the contradicting example** in the DTO Output fields part of
the kill-the-obvious checklist: split into one named test per field
instead of stacking asserts. Mention the "assert the whole Output
against a literal" alternative explicitly (still ONE assertion).
2. **Promote the rule to a dedicated sub-section** (§ 4a) with:
- The rationale stated plainly: "should fail for ONE reason".
- A forbidden / required code example so the failure mode is
concrete (the second assert never reached, the test name lies).
- A "tolerated forms" sub-section (§ 4b) for the legitimate
multi-statement patterns: ``pytest.raises`` + ``.context`` check,
and DTO equality against a literal.
Both changes catch the case the user flagged: a future agent reading
the skill should see the rule before the mutmut checklist, and find
the right example to copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``CurrentUser`` now carries a ``name: str | None`` field alongside ``email``. The production ``JwtTokenVerifier`` extracts ``name`` from the JWT claims (best-effort — not every IdP issues it by default; Azure AD requires the ``profile`` scope). The test ``FakeTokenVerifier.accept`` takes the same kwarg. This is the minimum change so an upcoming SSO auto-provisioning use case can decide whether to create a ``User`` row from the JWT claims alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shipped User reference feature was a "first user signup" demo with a public ``POST /v1/users`` accepting an email + name. That pattern does not survive contact with reality — every SOMA service deployment relies on an external IdP (Azure AD, Auth0, Keycloak…) for identity. This commit re-shapes the reference feature around the SSO-backed flow that production services actually need. **Domain changes** - ``User`` entity gains a ``subject: str`` field (the IdP-issued ``sub`` claim). The internal UUID ``id`` is kept so an IdP migration does not break references — ``subject`` is the lookup key, ``id`` is the domain identifier. - New ``MissingProfileClaimsError`` (mapped to 401) raised when the JWT lacks ``email`` or ``name`` claims required to provision a row. **Application changes** - New ``EnsureUserExistsUseCase`` — idempotent find-or-create driven by ``CurrentUser``. Returns the existing row if the subject is known, provisions a fresh one on first sight and publishes ``user.provisioned``. - New DTOs ``EnsureUserExistsInput`` / ``EnsureUserExistsOutput``. - ``UserRepository`` Protocol gains ``find_by_subject(subject) -> User | None`` as the primary lookup; ``find_by_email`` stays as a secondary helper for admin / migration paths. - The old ``CreateUserUseCase`` + its DTOs are deleted (no more public user-creation endpoint). **Infrastructure changes** - ``UserModel`` adds a ``subject`` column (UNIQUE, indexed); migration ``0001_init_users`` is rewritten to include it from the start. - ``SqlAlchemyUserRepository`` implements ``find_by_subject`` and translates the dual-uniqueness IntegrityError (subject OR email). - The Fake repository mirrors both invariants. **Presentation changes** - ``POST /v1/users`` is deleted (route, schema, dependency, container factory, tests, e2e). - ``GET /v1/users/me`` is added, returning the caller's persisted ``User`` row. The handler depends on ``get_or_provision_user``, which chains ``get_current_user`` → ``EnsureUserExistsUseCase``. On the first authenticated request for a JWT subject, a row is created from the JWT claims; subsequent requests reuse it. **Out of scope (documented)** - No FK from other tables to ``users.id`` yet — the relationship is by convention, not enforced. - No explicit registration endpoint — auto-provisioning is lazy, on the first authenticated call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auth chain used to read the ``Authorization`` header directly from ``Request``, which meant the OpenAPI document carried no security scheme — Swagger UI showed no "Authorize" button and devs had to add a custom header on each request. This commit switches ``get_bearer_token`` to ``Security(HTTPBearer(..., auto_error=False))``. Concrete UX: - Swagger UI now displays an "Authorize" button. - Click it once, paste a JWT, and every subsequent request from the UI is authenticated automatically (paste-once flow). - ``auto_error=False`` keeps our own ``MissingTokenError`` path so the error mapping in ``error_handlers.py`` stays the single source of truth (no FastAPI-default ``HTTPException`` leaking through). Out of scope: the full OAuth2 Authorization Code flow (one-click SSO redirect to the IdP). That requires an Azure / Auth0 / Keycloak app registration for the Swagger UI itself + dedicated env vars and is left as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit shipped ``HTTPBearer`` (paste-once UX). This commit
adds the missing leg: when the IdP endpoints are configured, the API
publishes an ``OAuth2AuthorizationCodeBearer`` security scheme and
Swagger UI renders a clickable Authorize button that triggers the real
SSO redirect flow (Authorization Code + PKCE). The dev clicks once, is
sent to Azure / Auth0 / Keycloak, logs in, lands back on /docs with the
token already populated.
Both schemes ship in the code; the runtime picks based on Settings:
- ``SWAGGER_OAUTH2_AUTHORIZATION_URL`` + ``SWAGGER_OAUTH2_TOKEN_URL``
set → ``OAuth2AuthorizationCodeBearer`` is published.
- Either URL empty → fall back to ``HTTPBearer`` (paste-once).
Switching is just env vars — no re-bake, no code change.
**Cookiecutter integration**
A new variable ``swagger_auth_scheme`` (``http_bearer`` default, or
``oauth2_auth_code``) controls the **baked defaults only**: which
values fill the ``.env.example`` and which README section is most
prominent. The code supports both regardless of the choice — so a
project that picks ``http_bearer`` at bake time can still flip to
``oauth2_auth_code`` later by editing env vars (no re-bake).
**New env vars**
- ``SWAGGER_OAUTH2_AUTHORIZATION_URL``
- ``SWAGGER_OAUTH2_TOKEN_URL``
- ``SWAGGER_OAUTH2_CLIENT_ID``
- ``SWAGGER_OAUTH2_SCOPES`` (comma-separated; defaults to
``openid,email,profile``)
- ``SWAGGER_OAUTH2_PKCE_ENABLED`` (default ``true``)
**FastAPI side**
- ``presentation/api/dependencies/auth.py::_build_security_scheme``
reads ``Settings`` at module load and returns either
``OAuth2AuthorizationCodeBearer`` or ``HTTPBearer``.
- ``presentation/api/app.py`` wires ``swagger_ui_init_oauth`` when
``SWAGGER_OAUTH2_CLIENT_ID`` is set so the Authorize button knows
which client to identify as.
- ``get_bearer_token`` accepts both ``HTTPAuthorizationCredentials``
(from ``HTTPBearer``) and ``str`` (from ``OAuth2AuthorizationCodeBearer``).
**Doc**
``docs/swagger-oauth2.md`` ships with an Azure AD app-registration
walkthrough (the primary use case), plus shorter notes for Auth0 /
Keycloak / Okta. Covers the SPA + PKCE configuration, the matching
``.env`` values, and a verification checklist.
**Side fix**
``forced-separate = ["tests"]`` added to ruff's isort config so test
files have a deterministic import order regardless of the package
name chosen at bake time (alphabetical sort otherwise depends on the
first letter of ``{{ cookiecutter.package_name }}``). All test files
updated accordingly. A multi-line wrap was applied to the ``import
event_handlers # noqa`` line in ``outbox_relay.py`` for the same
length-robustness reason.
**Bake test**
A new ``swagger-oauth2`` combo asserts that the right ``.env.example``
defaults render with each value of ``swagger_auth_scheme``.
Both bake flavours (default + oauth2_auth_code) verified: lint clean,
31 unit tests pass, 16 e2e tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The application-side env vars added by the previous commit (``SWAGGER_OAUTH2_*``) were not surfaced in the Helm chart — a production deployment had no clean way to configure them via values overlays. Adding them now closes that loop. - ``values.yaml`` declares five new keys under ``config``: ``swaggerOauth2AuthorizationUrl`` / ``swaggerOauth2TokenUrl`` / ``swaggerOauth2ClientId`` / ``swaggerOauth2Scopes`` (default ``openid,email,profile``) / ``swaggerOauth2PkceEnabled`` (default ``true``). - ``templates/configmap.yaml`` renders the matching ``SWAGGER_OAUTH2_*`` entries — picked up by both the API and the relay Deployments via the existing ``envFrom: configMapRef`` wiring (no template change there). - ``values.schema.json`` validates the new keys (URL strings, plus a ``"true" | "false"`` enum for the PKCE switch since env vars are strings). All keys default to empty / safe values, so an existing project that bumps this chart version with no overlay change keeps the HTTPBearer paste-once UX. Switching to the OAuth2 redirect flow in production is a values-overlay change only: ```yaml config: swaggerOauth2AuthorizationUrl: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize swaggerOauth2TokenUrl: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token swaggerOauth2ClientId: <swagger-ui-app-id> ``` Verified with ``helm lint`` (clean) and ``helm template`` (env vars render correctly with both defaults and explicit overrides). Template bake tests stay green (11/11). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…potency) Audit of Settings vs ConfigMap surfaced 8 application env vars that existed in ``Settings`` but were not routed through the Helm chart — meaning a production deployment had no clean values-overlay path to tune them (would have required a bespoke ``extraEnv:`` on the Deployment). - **Database pool**: ``dbPoolSize`` / ``dbPoolMaxOverflow`` / ``dbPoolTimeout``. Critical to tune per environment based on concurrent request count vs. Postgres ``max_connections``. - **HTTP middleware**: ``requestMaxBodyBytes`` / ``requestTimeoutSeconds``. Different per service profile (upload-heavy vs. CRUD). - **Idempotency**: ``idempotencyEnabled`` / ``idempotencyMethods`` / ``idempotencyTtlSeconds``. Tunable per env without code changes. Defaults in ``values.yaml`` match the ``Settings`` defaults exactly — zero behaviour change for an existing project bumping the chart with no overlay change. Schema validates the new keys (integer patterns for the numerics, ``"true" | "false"`` enum for the bool, free string for ``idempotencyMethods``). Excluded from this pass: - ``RELAY_METRICS_PORT`` — coupled to a hardcoded ``9100`` in ``relay-service.yaml``; touching one side alone breaks the scrape link, would need a coordinated edit. - ``APP_NAME`` — already exposed but as ``.Release.Name``, no override knob needed. ``helm lint`` + ``helm template`` confirm the rendering. Template bake tests stay green (11/11). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweeps the last stale `CreateUserUseCase` / `POST /v1/users` mentions left behind by the SSO refactor (now `EnsureUserExistsUseCase` and `GET /v1/users/me`). Updates the architecture sequence diagram, the README "removing the reference feature" runbook, the skill examples, and the tracing decorator docstring. Also adds the missing `# GIVEN` marker on the no-credentials auth dependency test so the test-naming quality gate passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Claude PostToolUse(Edit/Write) observer hook now also drives ruff on the touched Python file: `--fix` applies safe auto-fixes (import sort, unused imports, trivial style), `ruff format` reformats. Any unfixable diagnostic is printed to stderr so Claude sees it on the next turn and self-corrects. `ty` is intentionally NOT invoked here — keeping the hook fast — type checking stays on pre-commit and CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…spx) Closes two parking items from the spec at once: - Rate limiting (slowapi): absent from the template. Documented in docs/architecture.md §Production posture — rate limiting belongs at the ingress / CDN layer; business-rule throttling belongs in the use case. slowapi in `memory://` (default) is broken under multi-replica, and coupling to Redis just for this would be disproportionate. - httpx adapter tests: respx is the canonical pattern, not VCR or pytest-httpx. A new §"Testing external HTTP adapters" in the building-a-feature skill walks through the pattern, with an explicit GIVEN/WHEN/THEN test asserting both response parsing AND outbound payload shape (one test, one reason to fail). The dependency is intentionally NOT embarked yet — the skill instructs `uv add --dev respx` when the first external adapter actually ships. CLAUDE.md routing table gets the new trigger row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coverage holes were left after the SSO refactor: 1. The RS256 + JWKS branch of `JwtTokenVerifier` (the real-IdP path against Auth0 / Azure AD / Keycloak) had zero tests — only HS256 was exercised. 2. The `name` claim added to `CurrentUser` for SSO provisioning was never asserted on the verifier output. Adds two new integration tests: - `test_given_token_with_name_claim_when_verifying_then_extracts_name` - `test_given_valid_rs256_token_when_verifying_then_fetches_jwks_and_returns_current_user` — generates an RSA keypair, builds a JWKS via PyJWT's RSAAlgorithm.to_jwk, signs an RS256 token with kid, and verifies end-to-end. Implementation note worth flagging: PyJWKClient uses urllib internally, not httpx, so respx cannot intercept its JWKS fetch. The test monkeypatches `PyJWKClient.fetch_data` instead — the correct tool for third-party libs that bypass httpx. The skill section "Testing external HTTP adapters" now documents this edge case explicitly so the rule "httpx → respx, anything else → monkeypatch on the fetcher" is discoverable. `respx` is added to `[dependency-groups].dev` — the JwtTokenVerifier JWKS path is itself an external HTTP adapter, and any future httpx-based adapter will need respx anyway, so YAGNI no longer applies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…en guard Closes the §24 parking item on the `database=none` combo. The reference feature (SSO User flow) is intrinsically persisted, so offering 'none' in the interactive menu was a trap: users would pick it and immediately hit the pre_gen_project hook rejection. Two cleaner options were on the table — A.1 (remove from menu, keep defensive guard) and B (introduce a separate `include_example` variable that multiplies the combo matrix). Picked A.1: the "scaffolding without the reference feature" need is already covered by the README's "Removing the reference feature" runbook (post-bake `git rm`), so a dedicated cookiecutter variable would be surface for marginal value. - `cookiecutter.json`: `database: ["postgres", "sqlite"]` and the prompt description points to the README runbook for DB-less services. - `hooks/pre_gen_project.py`: unchanged. The rejection now defends against programmatic overrides (`--extra-context database=none`, CI scripts) rather than menu input. - `tests/test_template_bake.py`: the bake test is renamed `test_given_database_none_forced_via_extra_context_when_baking_then_aborts` to reflect what it actually verifies now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…istory The §1-23 design sections were a snapshot of "what to build"; the code is now that thing. The §24 decision log and §25 acceptance criteria were the only living parts, and they are already preserved respectively in commit messages (which capture the *why* of each resolved decision) and in the bake-test suite (which encodes the acceptance checks). Keeping the file longer would invite drift between spec and code. - Root CLAUDE.md: editing workflow no longer references the spec; commit messages are now the source of truth for design rationale. - Root README.md: the "Authoritative design" link is dropped; the database variable description also drops the obsolete 'none' value left over from a previous menu. - docs/validation/2026-05-20-todo-walkthrough.md: the citation to the spec §24 is rewritten to point at git history instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two walkthrough files documented a phase that has since concluded: every P0/P1 followup identified there has been resolved in a dedicated commit (e2c23cd, c92623d, and the chain that followed). The remaining purpose of the directory was historical narrative, which the git log already provides chronologically. Root docs/ is now empty and removed entirely. Generated projects keep their own docs/architecture.md and docs/adr/ — that is where the project-shaped design rationale lives going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nce-oriented The original prompts were written from the implementer's perspective — they mentioned internal file paths (scripts/export_openapi.py, presentation/cli/), kebab-case/snake_case naming details, "ship a Helm chart", and "wire OpenTelemetry instrumentations". A first-time user baking a new service should not need to decode any of that; they should read each prompt and immediately know what real-world consequence the choice has. Switched all prompts to French (matches the audience: SOMA developers baking a project) and rewrote each non-trivial one so it answers "what does picking this actually mean for me in practice": - include_helm: now reads "Générer la config pour rendre l'application disponible à SOMA" — the consequence, not the mechanism. - include_cli: "Pouvoir appeler la logique du service depuis le terminal en plus de l'API HTTP" — what it lets you do. - database: explains that sqlite is for the local devcontainer (lightweight) and postgres for the Helm deployment. - swagger_auth_scheme: explains the UX choice using the metaphor of "paste any token vs. one-click login tied to your IdP". - license: explains in plain language who can use the code under each option, including when MIT vs Apache-2.0 actually matters (patents). Value tokens (postgres/sqlite, yes/no, http_bearer/oauth2_auth_code, proprietary/MIT/Apache-2.0) are unchanged — they remain technical keys consumed by the Jinja templates and hooks. Only the human-facing __prompts__ descriptions change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The devcontainer's postStartCommand runs `just migrate` as soon as the app container is reported "started", which happens shortly after postgres becomes "healthy". On a fresh `docker compose up` from a brand new compose project (the case for every `Reopen in Container` after a bake), Docker's embedded DNS sometimes hasn't propagated the postgres service hostname yet — alembic hits asyncpg, asyncpg hits getaddrinfo, and the call returns EAI_AGAIN (errno -3, "Temporary failure in name resolution"). The migration aborts, the relay sidecar then loops on `outbox_events` not existing, and the user has to manually re-run `just migrate` to recover. The fix is a short retry loop in the `migrate` recipe itself: up to 6 attempts with 2s spacing (12s total budget). That window is generous enough to absorb a DNS / startup race, narrow enough that a real postgres outage still surfaces quickly. The retry is inline in the just recipe — no new dependencies, no extra script files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…forwardPorts Two projects baked from this template used to collide on host ports 5432 / 16686 / 9100, forcing the user to manually stop the older devcontainer before rebuilding. The compose file now declares no host-port bindings at all — VS Code's `forwardPorts` is the single mechanism that exposes ports on the host, and it transparently picks free local ports if the defaults are taken. Several projects coexist out of the box with zero manual steps. Three small UX touches so the experience stays one-click: - portsAttributes on 8000 sets `onAutoForward: openPreview` so the Swagger UI opens in a side panel as soon as the container attaches. - A `/` route on the FastAPI app redirects to `/docs`, so that auto- open preview lands on the actual documentation page rather than a 404. - portsAttributes on 5432 / 16686 stay silent / notify respectively (postgres has no UI to open; Jaeger pops a clickable notification only if the user opts in). The `just ports` recipe added a few minutes ago is removed — there is no manual lookup step anymore. The README first-time section is updated to describe the new behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The skill tdd-workflow §4a is explicit: a single assert per test, a
test should fail for ONE reason. The reference tests that ship with
the template (User SSO, JWT verifier, health/auth/metrics/middleware
e2e) were violating this — multiple shipped examples had 2-5 asserts
per test. New users baking the template were copying the wrong shape.
The tolerated form per §4b is a single equality on the full output
(or a tuple slice of it). Applied throughout:
- Tests that asserted multiple distinct concerns (e.g. status code
AND error code AND body content) are split into focused tests, one
assert each.
- Tests that asserted multiple fields of the same response are
collapsed to a single equality, either on the full body dict or on
a projected tuple — `assert (status, body) == (200, {...})` or
`assert (user.subject, user.email, user.roles) == ("...", ...)`.
- Tests with type-narrowing on `Optional` results
(`assert found is not None; assert found.subject == "..."`) become
`assert (found and found.subject) == "..."` — a single equality
that also handles the None case.
Files touched (10):
- tests/unit/domain/test_email.py
- tests/unit/domain/test_user.py
- tests/e2e/api/test_users_me_endpoint.py (3 → 7 tests after split)
- tests/e2e/api/test_middleware.py
- tests/e2e/api/test_health_endpoints.py
- tests/e2e/api/test_auth_endpoints.py
- tests/e2e/api/test_metrics.py (1 → 3 tests after split)
- tests/integration/infrastructure/test_user_repository.py
- tests/integration/infrastructure/auth/test_jwt_verifier.py
The integration tests under tests/integration/jobs/ (outbox relay) and
tests/integration/middleware/ (idempotency) are NOT in this commit —
they have a denser multi-assert pattern that needs a more careful
split, handled in the follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continues the §4a sweep. These two integration suites had the densest violations — each test verified the outcome on three distinct observables (function return value, in-process handler invocation, DB row state). That is exactly the "asserts repo existence AND asserts an event was sent" pattern the rule is designed to reject: when a single test fails, the test name can't tell you which of the three observables broke. Each multi-assert test is now split by observable. Within a single observable that has multiple fields (e.g. an outbox row's terminal state — status + published_at + attempts), the assertions are collapsed to a single tuple equality (§4b: equality on a whole DTO is one assertion). A small ``_row(**overrides)`` helper in the outbox suite removes the 40-line model construction boilerplate that was repeated across tests once they were split per observable. - tests/integration/jobs/test_outbox_relay.py: 7 tests → 17 tests - tests/integration/middleware/test_idempotency.py: 6 tests → 14 tests Verified end-to-end on a fresh bake: `scripts/checks/test_naming.py` passes (GIVEN/WHEN/THEN markers on every new test), `ruff check` clean, `pytest -m unit` green, and the full bake-test suite is 12/12. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on and uv Three fixes triggered by a fresh bake with python_version=3.14: 1. pydantic / pydantic-settings floors were too low. `pydantic>=2.10` predates Python 3.14 by almost a year; resolvers picked a release without a 3.14 wheel and the install fell over. Bumped to `pydantic>=2.12` (first release with 3.14 wheels) and `pydantic-settings>=2.13` (matching baseline). 2. The devcontainer used `mcr.microsoft.com/devcontainers/python:3.X` as the base, which lags real Python patch releases by weeks — a user baking today got 3.14.4 instead of 3.14.5. Switched the base to `mcr.microsoft.com/devcontainers/base:bookworm` (no Python) and added `RUN uv python install <version>`. uv tracks the latest patch from Astral's release index, so every fresh image rebuild picks up the current 3.X.Y automatically. 3. uv was installed via `COPY --from=ghcr.io/astral-sh/uv:latest /uv ...`. That layer is heavily Docker-cached: a cold rebuild on a host with an older layer pinned `uv` to whatever was pulled the first time. Switched to `RUN curl -LsSf https://astral.sh/uv/install.sh | sh`, which always fetches the latest release at build time. CI is unaffected: it already uses `astral-sh/setup-uv@v7` which fetches the latest action release on every run, and the production Dockerfile (separate from the devcontainer) uses `python:X.Y-slim` from upstream which tracks patches reliably. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The variable existed in cookiecutter.json with a prompt, but the template ships no `presentation/cli/` directory anywhere. The post_gen_project hook's `remove_path` for it was a silent no-op (the function tolerates non-existent paths), so picking yes or no produced identical projects. Removed the option from cookiecutter.json (both the choice list and the prompt), the variable from hooks/post_gen_project.py, and the matching row from the root README's variables table. Bake tests unaffected — they never referenced this option. If a CLI presentation layer is wanted in the future, it should ship as real generated code (entry point, a couple of commands, tests) rather than as a hidden flag for a non-existent module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* post_gen now produces a commit per logical step (initial template tree, then a 'chore: lock dependencies' commit for uv.lock) so a freshly baked project starts with a clean working tree and a readable bootstrap history. * alembic upgrade head is skipped at post_gen for postgres — the DB host is only reachable from the devcontainer network, so the 30s asyncpg timeout was paid on every bake test. Bake suite: 5min8s → 31s. sqlite keeps the in-place migrate. * justfile hooks recipe now also reinstalls the default 'pre-commit' hook type. Without it, the devcontainer postCreateCommand only reinstalled commit-msg + pre-push, and the leftover pre-commit hook still pointed at the host venv path that does not exist inside the container. * Add 'just test-watch' (pytest-watcher) and 'just test-parallel' (xdist). Watch is the new primary for the TDD inner loop; parallel stays opt-in because each xdist worker spawns its own testcontainers Postgres. * Gitignore the local sqlite dev DB and its WAL/SHM siblings. * settings.json schema URL bumped to the canonical schemastore.org location. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous .env.example for swagger_auth_scheme=oauth2_auth_code pointed at Azure AD with <tenant-id> placeholders, so Swagger UI's Authorize button broke out of the box. A non-technical user had to register two app identities at an IdP before they could click anything. Now, when oauth2_auth_code is selected: * .devcontainer/docker-compose.yml adds a Keycloak 26 service starting in start-dev --import-realm, healthchecked, depended on by the app service. * .devcontainer/keycloak/realm-export.json declares realm 'dev', two seeded users (alice/alice, admin/admin), a public PKCE swagger-ui client whose redirect URI matches /docs/oauth2-redirect, and an audience-mapper client scope that injects aud=api into access tokens. * .env.example points AUTH_JWT_ISSUER at http://localhost:8080/realms/dev (what the browser-issued token carries) while AUTH_JWT_JWKS_URL points at http://keycloak:8080/... (intra-docker hostname). The verifier validates the two independently — issuer matches the token's iss claim, JWKS is fetched server-side. * devcontainer.json forwards port 8080 silently. * docs/swagger-oauth2.md opens with a TL;DR pointing the dev at alice/alice before the longer explanation of how to swap to Azure / Auth0 in staging. For swagger_auth_scheme=http_bearer the post_gen hook removes the keycloak directory; the bake test asserts both presence and absence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the e2e suite overrode get_ensure_user_exists_use_case with a Fake backed by InMemoryUserRepository + FakeTokenVerifier, plus a StubAsyncSession. The HTTP layer was real but the use case / persistence / JWT layers were not, so e2e was effectively a slightly-fatter unit test. Now e2e exercises the real production wiring: * tests/e2e/conftest.py mounts get_session on a real AsyncSession bound to testcontainers Postgres (per-test outer transaction, savepoint-mode sessions inside requests, rollback at teardown — same pattern as integration's pg_session). * get_token_verifier is overridden with a real JwtTokenVerifier configured HS256 against a known test secret; tests mint legitimate JWTs via the new 'mint_token' fixture instead of pasting a Fake-accepted string. * httpx.AsyncClient + ASGITransport replaces TestClient because the sync client runs the handler on an anyio portal thread, which is a different event loop than the test fixtures — calling SQLAlchemy async sessions across loops raises 'Future attached to a different loop'. * pg_url + pg_engine moved from tests/integration/conftest.py to tests/conftest.py so both integration and e2e share the same session-scoped Postgres container. * tests/integration/conftest.py keeps pg_session (savepoint rollback). * tests/integration/.../test_jwt_verifier.py: HMAC secrets bumped to >=32 bytes to silence InsecureKeyLengthWarning surfaced by the full run. Folder layout: tests/unit/use_cases/ becomes THE dedicated home for use case tests with Fakes. test_ensure_user_exists_use_case.py moves into it as test_ensure_user_exists.py — the '_use_case' suffix is redundant when the folder already says so. Domain drills, pure helpers in infrastructure / presentation, and contract checks (error mapping exhaustiveness) stay where they are at tests/unit/<layer>/...; their absence from use_cases/ is the discriminator. The doctrine update in this area lands with the skills doctrine commit. Net: 105/105 on a freshly baked project, 0 warnings, 5.4s end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* alembic/versions/0001_init.py replaces the four-revision chain
(0001_init_users → 0002_init_outbox → 0003_outbox_retry_columns
→ 0004_init_idempotency). The template has never been deployed, so
a single bootstrap revision is honest history. Removing the user
feature now requires editing the users block of 0001_init instead of
rm-ing a file; the README's drop-instructions section reflects that.
* CLAUDE.md step 5 of the agent flow becomes a MANDATORY HARD GATE: the
agent stops after posting the plan, waits for an explicit affirmative
('go', 'ok', 'validé', …) before any Edit/Write/git action. Silence,
clarifying questions, partial acks, or new context are NOT approval.
Triggered by repeated observed drift toward early action.
* CLAUDE.md step 7 introduces the commit-per-feature step. building-a-feature
§11 owns the discipline: one feature = one conventional-commit, no
--no-verify, never amend a commit the user has already seen; if a hook
fails, fix and create a NEW commit.
* tdd-workflow §3 'E2E proves wiring' is rewritten — e2e uses real adapters
by default, in-memory Fakes are reserved for tests/unit/. StubAsyncSession
is documented as the legitimate exception for the DB-outage probe test.
* tdd-workflow §Pyramid is widened to make the discriminator explicit:
tests/unit/ is 'is it pure?' (no I/O), not 'is it a use case'. use_cases/
is the ★ primary citizen; domain drills, pure infrastructure helpers,
pure presentation contract tests are legitimate secondary citizens.
* tdd-workflow §4c documents 'extract repeated GIVEN into fixtures' with the
scope ladder (file-local → layer conftest → root conftest) and the
'< 2 occurrences → keep inline' guard against premature indirection.
* onboarding-soma 'File and folder names' adds the group-by-aggregate rule:
kind-first always; once a kind folder holds 3+ files for one aggregate,
group them under an aggregate sub-folder (use_cases/todo_list/) and drop
the aggregate prefix from filenames. Per-kind threshold; promotion happens
in one edit, no half-state.
* All skill references to 0001_init_users.py / tests/unit/application/
test_..._use_case.py are realigned to 0001_init.py / tests/unit/use_cases/
test_<verb>_<noun>.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lint forbade ``float`` annotations in ``src/<pkg>/domain/`` to nudge contributors towards ``Decimal`` for money. In practice the rule generated more confusion than value — ``float`` is legitimate for ratios, probabilities, loss functions, and geometry. The prose guidance ``Decimal for money / precise quantities`` already lives in writing-domain-code/SKILL.md and is the right home for nuance the AST check could not express. Removed: * scripts/checks/no_float_in_domain.py * the matching block in .pre-commit-config.yaml * the matching run in .github/workflows/ci-quality.yml * the matching step in scripts/checks/quality.sh * the bullet in onboarding-soma/SKILL.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bake tests legitimately invoke `uv` / `pytest` from PATH against the
freshly-baked project, exactly as a user would. Ruff S607 ("partial
executable path") flagged the five subprocess.run calls in
test_template_bake.py since CI uses a recent ruff that enforces it.
The hooks/ tree already has the same exception for the same reason —
extend it to tests/ to keep the CI green and the intent obvious.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.