Source: codebase audit, 2026-06-11 (audit finding F11, Tier 3)
Severity: Low
Category: Type safety / API contract
Problem
`list_runs` and `get_run` return `dict[str, Any]` from psycopg's `dict_row` factory. The shape comes straight out of the SQL.
Consequences:
- A column rename in `ops.etl_runs` (or a future view change) silently alters the JSON shape the admin UI consumes.
- No type-checked contract between the service layer and the router that exposes `/admin/etl/runs`.
- The frontend's `EtlRun` TypeScript type is the only thing pinning the shape, and it's hand-maintained.
Suggested approach
Add a Pydantic model in `api/etl/admin_service.py` (or a colocated `schemas.py`):
```python
class EtlRunOut(BaseModel):
run_id: UUID
started_at: datetime
completed_at: datetime | None
status: Literal['running', 'success', 'failed']
source_row_counts: dict[str, int]
mart_row_counts: dict[str, int] | None
dbt_version: str | None
git_sha: str | None
```
Convert the `dict_row` rows via `EtlRunOut.model_validate(row)` in `list_runs` / `get_run`. Update the router to declare `response_model=list[EtlRunOut]`.
Verification
`uv run mypy api/` and `uv run pytest` clean; the admin UI continues to render the runs page.
Priority
Defensive — no current bug. Worth bundling into another touch of `admin_service.py`.
Source: codebase audit, 2026-06-11 (audit finding F11, Tier 3)
Severity: Low
Category: Type safety / API contract
Problem
`list_runs` and `get_run` return `dict[str, Any]` from psycopg's `dict_row` factory. The shape comes straight out of the SQL.
Consequences:
Suggested approach
Add a Pydantic model in `api/etl/admin_service.py` (or a colocated `schemas.py`):
```python
class EtlRunOut(BaseModel):
run_id: UUID
started_at: datetime
completed_at: datetime | None
status: Literal['running', 'success', 'failed']
source_row_counts: dict[str, int]
mart_row_counts: dict[str, int] | None
dbt_version: str | None
git_sha: str | None
```
Convert the `dict_row` rows via `EtlRunOut.model_validate(row)` in `list_runs` / `get_run`. Update the router to declare `response_model=list[EtlRunOut]`.
Verification
`uv run mypy api/` and `uv run pytest` clean; the admin UI continues to render the runs page.
Priority
Defensive — no current bug. Worth bundling into another touch of `admin_service.py`.