Skip to content

All Rework - #7

Open
vianneybacoup wants to merge 90 commits into
mainfrom
rework
Open

All Rework#7
vianneybacoup wants to merge 90 commits into
mainfrom
rework

Conversation

@vianneybacoup

Copy link
Copy Markdown
Collaborator

No description provided.

vianneybacoup and others added 30 commits May 5, 2026 23:16
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>
vianneybacoup and others added 30 commits May 20, 2026 15:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant