Architecture
Repo layout
Section titled “Repo layout”CivicDog is deliberately split into small, single-responsibility repos rather than one giant monorepo:
cd-platform— a Python monorepo containing three independently versioned, independently deployed services:cd-etl(the Airflow ingestion pipeline),cd-api(the FastAPI service that servescd-lookup), andcd-server(the app backend forcd-webapp). Each has its ownpyproject.toml, its own README, and its own release tag pattern (cd-etl-v*,cd-api-v*,cd-server-v*). Alongside them sitscd-lib, a shared library (see below).cd-webapp— the React web app atapp.civicdog.com, the core product experience. Its own toolchain (npm, its own CI), entirely separate from the Python services it talks to.cd-lookup— a WordPress plugin, PHP, entirely separate tooling (Composer, PHPUnit, WordPress Coding Standards) from the Python side.cd-infra— Terraform only. No application code. Infra changes are reviewed and applied independently of app deploys.
This separation means a change to the WordPress plugin never touches Python CI, and a Terraform change never triggers an application redeploy.
Shared code without a package registry: cd-lib
Section titled “Shared code without a package registry: cd-lib”All three Python services depend on cd-lib: the Pydantic and JSON:API models cd-server validates
cd-api’s responses against, the Bedrock embedding client shared by cd-etl and cd-api, and the
congressional apportionment table used to validate districts. A few deliberate choices:
- A local path dependency, not a published package or a
uvworkspace. Each service keeps its ownpyproject.tomland lockfile and points at../cd-lib. No registry to run, and no shared lockfile coupling three independently deployed services together. - Only what’s actually shared. Models that only one service uses stay in that service.
cd-libisn’t a dumping ground. - Liberal in what it accepts. The shared models ignore unknown fields. Services deploy independently,
so if a new
cd-apiresponse field broke validation in an oldercd-server, every additive API change would force a lockstep deploy. - Editable installs only where they’re safe. The container-built services install
cd-libas editable;cd-api’s Lambda zip doesn’t. An editable install there produces only a.pthfile pointing at the build machine’s checkout, not real copied files — found empirically, and it silently breaks the deployed zip. - One
cdnamespace from two places. Each service’scd.<service>andcd-lib’scd.libare installed from separate locations, so no service has its owncd/__init__.py. That makescdan implicit PEP 420 namespace package that merges both, rather than one hiding the other.
Single source of truth: current_congress()
Section titled “Single source of truth: current_congress()”The data model lives in Postgres, defined by Alembic migrations in cd-etl:
congresses— one row per numbered Congress, with a date range.members— one row per Bioguide ID: biographical identity, and aparty_historyJSONB timeline (members can and do change party).member_terms— one row per member per Congress they served in (chamber, district, term dates).bills/bill_subjects— bills referenced by a roll call vote, with their CRS policy area, legislative subjects, latest CRS summary, and a pgvectorcrs_summary_embedding.roll_calls/roll_call_member_votes— House roll call votes and each member’s position.vocab_term_embeddings— one embedding per policy area / legislative subject, powering Semantic Search’s exact-match tier.current_members— a SQL view that joinscongresses,members, andmember_terms, derives “current party” via aLEFT JOIN LATERALon the party history, filters to whichever Congress is currently active, and exposes whether each member is stillin_office.
Both the ETL job and the API need to agree on “what Congress is current right now.” Rather than
duplicating that logic in Python and SQL and letting them drift, it’s a single SQL function,
current_congress(), that both the ETL and the current_members view call. cd-api’s member endpoints
never query the raw member tables. They only read current_members, so the API’s notion of “current”
can never diverge from the ETL’s. Bill search and voting records read bills, roll_calls, and their
related tables directly, since there’s no “current” question to get wrong there.
Local/prod parity
Section titled “Local/prod parity”cd-etl ships as the same Docker image locally and in production. Locally, make start-etl runs it in
Docker Compose against a local Postgres. In production, the exact same image (built once, tagged, pushed
to GHCR) runs as four ECS services: Airflow’s scheduler, DAG processor, triggerer, and API server. There’s no separate
“deployment version” of the container — what you run on your laptop is what runs in AWS, which rules out
an entire class of “works locally, breaks in prod” bugs.
The container entrypoint also runs migrations automatically on every start — both Airflow’s own metadata
migrations and the app’s Alembic migrations — so there’s no “forgot to migrate” failure mode either in
dev or in prod. cd-server follows the same model with its own image and database.
Design principles
Section titled “Design principles”- One source of truth per fact. “What’s the current Congress” lives in one SQL function, not scattered across services.
- Same artifact everywhere. Docker images are built once and promoted, never rebuilt per-environment.
- Small, single-purpose repos. Infra, ETL/API, the web app, and the WordPress consumer evolve and deploy independently.
- Defensive by default. Hash-guarded upserts, apportionment-validated districts, and standard error bodies (JSON:API error documents, RFC 9457 problem details) — see Data Pipeline and API for specifics.
