PH0.2 — API skeleton: core, router discovery, healthz, db + audit hook, alembic 0001, CLI #51

Merged
bart merged 8 commits from feature/api-skeleton into develop 2026-09-11 10:17:35 +00:00
Collaborator

Summary

PH0.2 — API skeleton: polaris.core, polaris.api (router discovery, /healthz, /metrics,
request-id middleware, RFC 9457 problem-details handler), polaris.db (models, session, audit
before_flush hook), Alembic 0001 (task, audit_log, setting), polaris.cli (command
discovery, api/openapi subcommands), sealed root tests/conftest.py, backend/Dockerfile.

Closes #4. Row PH0.2 in docs/12-implementation-plan.md.

Acceptance criteria

  • GET /api/v1/healthz returns green against a running Postgres; /metrics responds
  • A router module dropped into the routers package is mounted without editing any shared file
  • alembic upgrade head and downgrade base succeed for 0001 (task, audit_log, setting)
  • An unhandled domain error is returned as a problem-details body with a urn:polaris:error: type and request id
  • polaris openapi writes the spec; ruff, mypy --strict and pytest are green; backend Dockerfile builds

Notes for reviewers

  • backend/** except worker/ and auth.py (both left untouched for #6/#7); polaris.contracts
    and polaris.domain are not created here (later tickets).
  • The generic sa.Enum(..., create_type=False) silently drops create_type in the SQLAlchemy
    version pinned by uv.lock — the migration uses postgresql.ENUM(..., create_type=False)
    instead so the enum type isn't created twice by create_table's own DDL event. Commit message
    on the migration has the detail.
  • No metrics client library is in this ticket's declared dependency list (fastapi, sqlalchemy,
    psycopg, alembic, ortools, structlog, apscheduler, pydantic-settings, httpx), so /metrics is a
    small hand-rolled Prometheus text exposition rather than prometheus-client — swapping it in
    later is a one-file change (polaris/api/metrics.py).
  • Tests use postgres:16-alpine (already cached locally) rather than the compose stack's
    postgis/postgis image: nothing before PH1.2 needs a PostGIS type, so tests don't depend on
    pulling a large image over the network. PH1.2 will need to switch this fixture's image when
    geography-typed tables land.

Test plan

  • ruff check . / ruff format --check . — clean
  • mypy --strict (src/) — clean
  • pytest -m "not integration" — 22 passed
  • pytest -m integration (testcontainers Postgres) — 8 passed
  • Manual: alembic upgrade head / alembic downgrade base against a real running Postgres container
  • Manual: polaris openapi writes backend/openapi.json (committed)
  • docker build backend/ — succeeds; smoke-tested polaris --help and polaris openapi inside the built image

🤖 Generated with Claude Code

https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM

## Summary PH0.2 — API skeleton: `polaris.core`, `polaris.api` (router discovery, `/healthz`, `/metrics`, request-id middleware, RFC 9457 problem-details handler), `polaris.db` (models, session, audit `before_flush` hook), Alembic `0001` (`task`, `audit_log`, `setting`), `polaris.cli` (command discovery, `api`/`openapi` subcommands), sealed root `tests/conftest.py`, `backend/Dockerfile`. Closes #4. Row **PH0.2** in `docs/12-implementation-plan.md`. ## Acceptance criteria - [x] `GET /api/v1/healthz` returns green against a running Postgres; `/metrics` responds - [x] A router module dropped into the routers package is mounted without editing any shared file - [x] `alembic upgrade head` and `downgrade base` succeed for `0001` (task, audit_log, setting) - [x] An unhandled domain error is returned as a problem-details body with a `urn:polaris:error:` type and request id - [x] `polaris openapi` writes the spec; `ruff`, `mypy --strict` and `pytest` are green; backend Dockerfile builds ## Notes for reviewers - `backend/**` except `worker/` and `auth.py` (both left untouched for #6/#7); `polaris.contracts` and `polaris.domain` are not created here (later tickets). - The generic `sa.Enum(..., create_type=False)` silently drops `create_type` in the SQLAlchemy version pinned by `uv.lock` — the migration uses `postgresql.ENUM(..., create_type=False)` instead so the enum type isn't created twice by `create_table`'s own DDL event. Commit message on the migration has the detail. - No metrics client library is in this ticket's declared dependency list (fastapi, sqlalchemy, psycopg, alembic, ortools, structlog, apscheduler, pydantic-settings, httpx), so `/metrics` is a small hand-rolled Prometheus text exposition rather than `prometheus-client` — swapping it in later is a one-file change (`polaris/api/metrics.py`). - Tests use `postgres:16-alpine` (already cached locally) rather than the compose stack's `postgis/postgis` image: nothing before PH1.2 needs a PostGIS type, so tests don't depend on pulling a large image over the network. PH1.2 will need to switch this fixture's image when geography-typed tables land. ## Test plan - [x] `ruff check .` / `ruff format --check .` — clean - [x] `mypy --strict` (`src/`) — clean - [x] `pytest -m "not integration"` — 22 passed - [x] `pytest -m integration` (testcontainers Postgres) — 8 passed - [x] Manual: `alembic upgrade head` / `alembic downgrade base` against a real running Postgres container - [x] Manual: `polaris openapi` writes `backend/openapi.json` (committed) - [x] `docker build backend/` — succeeds; smoke-tested `polaris --help` and `polaris openapi` inside the built image 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
fastapi, sqlalchemy, psycopg, alembic, ortools, structlog, apscheduler,
pydantic-settings, httpx as runtime deps; pytest, testcontainers, ruff,
mypy as the dev group. ortools/apscheduler aren't used by this ticket
but are declared now (docs/12-implementation-plan.md PH0.2) so later
tracks never have to touch pyproject.toml. uvicorn arrives via the
fastapi[standard] extra rather than as a separate dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Settings (pydantic-settings, POLARIS_ env prefix), structlog config
that injects request_id/actor onto every log line, UTC/Europe-Amsterdam
time helpers, request-scoped contextvars (request_id, actor_sub,
actor_role), and the PolarisError hierarchy the RFC 9457 handler maps
to urn:polaris:error:<code>.

polaris.core only imports stdlib/pydantic/structlog per the import
rules table in docs/12-implementation-plan.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Base, engine/SessionLocal/session_scope, and typed mapped classes for
the three platform tables (task, audit_log, setting).

The Auditable mixin + before_flush listener is the mechanism hard rule
#3 (CLAUDE.md) depends on: any mapped class that inherits Auditable
gets its insert/update/delete mirrored into audit_log in the same
flush, reading actor/request_id from polaris.core.context. Setting
opts in (admin-editable, audited); Task opts out (internal queue
plumbing, not a domain object).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
- discover_routers() walks polaris.api.routers via pkgutil.walk_packages
  and mounts every module-level `router: APIRouter` under /api/v1 — a
  module dropped into routers/<aggregate>/<concern>.py is mounted with
  no shared file edited.
- platform/health.py: GET /api/v1/healthz, checking Postgres reachability.
- RequestIdMiddleware: mints/echoes X-Request-Id, available via
  request.state (survives an exception unwinding past this middleware)
  and polaris.core.context (for structlog/the audit hook).
- problem_details.py: PolarisError, StarletteHTTPException,
  RequestValidationError and any other unhandled Exception all become
  RFC 9457 application/problem+json bodies with a
  urn:polaris:error:<code> type and the request id.
- metrics.py: GET /metrics, a minimal hand-rolled Prometheus text
  exposition (no metrics client library is in this ticket's declared
  dependency list; swapping the internals for prometheus-client later
  is a one-file change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Same package-walk discovery pattern as router discovery: a module
dropped into polaris/cli/<command>.py exporting NAME and register()
becomes a `polaris <name>` subcommand, no shared file edited.

`polaris api` runs the FastAPI app with uvicorn (this is what the
compose api service, owned by feature/infra-compose-base, runs).
`polaris openapi` writes the spec to backend/openapi.json, committed
here as the frontend's contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
alembic.ini has no sqlalchemy.url; env.py sets it from
polaris.core.settings.get_settings().database_url (POLARIS_DATABASE_URL)
so there's exactly one place the DB connection string comes from.

0001 creates the three platform tables plus their indexes
(task(status, run_after), audit_log(entity, entity_id, at)) from
docs/08-data-model.md, and is fully reversible (downgrade drops
everything it created, including the task_status enum type).

Uses postgresql.ENUM(..., create_type=False) rather than the generic
sa.Enum for the status column: the generic type silently ignores
create_type (confirmed against the sqlalchemy version in uv.lock), so
create_table's own DDL event tried to CREATE TYPE a second time and
failed with "already exists".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
tests/conftest.py has db/session fixtures only (postgres_url, db_engine,
db_session, backed by testcontainers) per docs/12-implementation-plan.md
— sealed after this ticket; feature-specific fixtures belong in
tests/<level>/<package>/conftest.py, not here.

Uses plain postgres:16-alpine rather than postgis/postgis for the test
container: nothing before PH1.2 uses a PostGIS type, and the plain
image was already available locally so tests don't depend on pulling a
large image over the network.

Unit tests cover core (errors, time, context), router/CLI discovery
against synthetic packages, and the RFC 9457 handler end to end
(PolarisError, HTTPException, validation error, and a genuinely
unhandled exception, including the request-id-survives-the-unwind
case). Integration tests (testcontainers Postgres) cover healthz
against a real DB, the audit before_flush hook, and alembic upgrade
head / downgrade base.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
python:3.13-slim, uv for dependency install (two-layer: deps first from
pyproject.toml/uv.lock so it's cached independent of source changes,
then the project itself), entrypoint `polaris`, default command `api`.
Also runs the worker (`polaris worker`, PH0.3) and any CLI subcommand
(`polaris seed`, `polaris openapi`, …) by overriding CMD — one image,
role picked at container-run time, matching the compose services table
in docs/07-architecture.md.

Verified with `docker build backend/` standalone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
bart merged commit c8b5122d5d into develop 2026-09-11 10:17:35 +00:00
bart deleted branch feature/api-skeleton 2026-09-11 10:17:50 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
patrick/Polaris!51
No description provided.