feat(db): schema 0003 planning tables #66

Merged
bart merged 1 commit from feature/db-schema-planning into main 2026-09-11 12:04:48 +00:00
Owner

PH1.2 — Schema 0003: planning tables and typed mapped classes

Closes #10

Second half of row PH1.1 -> PH1.2 in docs/12-implementation-plan.md (PH1.1 / migration 0002 merged as #58). Strictly linear Alembic chain — this is the only migration PR open right now.

What this adds

Migration 0003 (backend/alembic/versions/0003_planning_tables.py) plus matching typed SQLAlchemy mapped classes (backend/src/polaris/db/models/) for every table in docs/08-data-model.md's "Planning" and "Execution and learning" sections:

solver_run, plan_version, plan_version_date, assignment, unassigned_job, plan_lock, validation_finding, service_feedback, reliability_score, notification.

Highlights (see docs/08-data-model.md for the full spec each of these implements):

  • assignment: all five documented indexes — (plan_version_id, shift_id, sequence_no), (job_id), (shift_id, status), plus the "Useful indexes" section's (shift_id, planned_start) and (plan_version_id, review).
  • plan_lock: AuditColumns + Auditable (status transitions active -> cleared/expired, same shape as job/shift/site_suitability), with one named CHECK constraint per lock type enforcing exactly the required columns for that type (pin needs job_id+shift_id+presence_window_id; reject_window needs job_id+site_id+date+start_at+end_at — deliberately not presence_window_id, per E21's "matched on (job, site, date, overlapping time range), not on window id"; etc. — full reasoning in the module docstring, including the two lock types the narrative docs never spell out, avoid_site_weekday and not_on_shift).
  • plan_version_date: composite PK (plan_version_id, date) (doc lists no id) + a partial unique index on date where is_active, for "exactly one active version per date".
  • reliability_score: unique (site_id, vehicle_id, weekday) with NULLS NOT DISTINCT (Postgres 15+) so two "all vehicles, all weekdays" rows for the same site collide.
  • Append-only tables (solver_run, plan_version, assignment, plan_version_date, unassigned_job, service_feedback, validation_finding): no updated_at/deleted_at, no Auditable mixin — matches ingest_record's PH1.1 precedent for the tables hard rule #1 names as append-only. plan_version/assignment/solver_run status transitions are audited by the polaris.domain service performing them (hard rule #3), not by generic before/after diffing here — there's no polaris.domain yet for this ticket to wire that into.

Verification

  • alembic upgrade head / downgrade -1 / upgrade +1 round-trips 0003 cleanly; full chain base -> head -> base migrates cleanly (new test_0003_upgrade_then_downgrade_minus_one_round_trips in tests/integration/db/test_migrations.py, run against a real postgis/postgis:17-3.4 container via testcontainers).
  • New tests/integration/db/test_planning_models.py round-trips the whole PH1.2 aggregate through the ORM and exercises the plan_lock per-type CHECK constraints, the plan_version_date partial-unique, and the reliability_score NULLS NOT DISTINCT unique against real Postgres.
  • mypy --strict and ruff clean; full backend suite green (142 passed, 5 skipped — the skips are the pre-existing Keycloak compose-stack smoke tests, unrelated).

Seam note (mirroring PH1.1's issue #57 precedent)

Two pre-existing tests outside this ticket's owned paths encoded "not there yet" assumptions that this migration resolves by design, so I fixed them directly rather than leave a regression, and I'm opening a seam-request issue for traceability:

  • tests/integration/db/test_migrations.py: the PH1.1 test that downgraded -1 from head to check 0002's round-trip now needs to target 0002 explicitly, since head is 0003. Added the 0003 equivalent alongside it.
  • tests/integration/api/test_metrics_db.py: PH6.6's /metrics solver_run gauges (api/metrics.py) were tested against solver_run not existing (has_table(...) returns False); now that it exists, rewrote that test to cover the empty-table (headers + zero gauge, no per-status line) and populated-table rendering instead. api/metrics.py itself needed no change — its docstring already anticipated this ("once the migration lands these lines start appearing with no further change here").

Neither db/audit.py, api/main.py, nor any other sealed/owned file was touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX

## PH1.2 — Schema 0003: planning tables and typed mapped classes Closes #10 Second half of row PH1.1 -> PH1.2 in `docs/12-implementation-plan.md` (PH1.1 / migration `0002` merged as #58). Strictly linear Alembic chain — this is the only migration PR open right now. ### What this adds Migration `0003` (`backend/alembic/versions/0003_planning_tables.py`) plus matching typed SQLAlchemy mapped classes (`backend/src/polaris/db/models/`) for every table in docs/08-data-model.md's "Planning" and "Execution and learning" sections: `solver_run`, `plan_version`, `plan_version_date`, `assignment`, `unassigned_job`, `plan_lock`, `validation_finding`, `service_feedback`, `reliability_score`, `notification`. Highlights (see docs/08-data-model.md for the full spec each of these implements): - **`assignment`**: all five documented indexes — `(plan_version_id, shift_id, sequence_no)`, `(job_id)`, `(shift_id, status)`, plus the "Useful indexes" section's `(shift_id, planned_start)` and `(plan_version_id, review)`. - **`plan_lock`**: `AuditColumns` + `Auditable` (status transitions `active -> cleared/expired`, same shape as `job`/`shift`/`site_suitability`), with one named `CHECK` constraint per lock type enforcing exactly the required columns for that type (`pin` needs `job_id`+`shift_id`+`presence_window_id`; `reject_window` needs `job_id`+`site_id`+`date`+`start_at`+`end_at` — deliberately not `presence_window_id`, per E21's "matched on (job, site, date, overlapping time range), not on window id"; etc. — full reasoning in the module docstring, including the two lock types the narrative docs never spell out, `avoid_site_weekday` and `not_on_shift`). - **`plan_version_date`**: composite PK `(plan_version_id, date)` (doc lists no `id`) + a partial unique index on `date` where `is_active`, for "exactly one active version per date". - **`reliability_score`**: unique `(site_id, vehicle_id, weekday)` with `NULLS NOT DISTINCT` (Postgres 15+) so two "all vehicles, all weekdays" rows for the same site collide. - **Append-only tables** (`solver_run`, `plan_version`, `assignment`, `plan_version_date`, `unassigned_job`, `service_feedback`, `validation_finding`): no `updated_at`/`deleted_at`, no `Auditable` mixin — matches `ingest_record`'s PH1.1 precedent for the tables hard rule #1 names as append-only. `plan_version`/`assignment`/`solver_run` status transitions are audited by the `polaris.domain` service performing them (hard rule #3), not by generic before/after diffing here — there's no `polaris.domain` yet for this ticket to wire that into. ### Verification - `alembic upgrade head` / `downgrade -1` / `upgrade +1` round-trips 0003 cleanly; full chain `base -> head -> base` migrates cleanly (new `test_0003_upgrade_then_downgrade_minus_one_round_trips` in `tests/integration/db/test_migrations.py`, run against a real `postgis/postgis:17-3.4` container via testcontainers). - New `tests/integration/db/test_planning_models.py` round-trips the whole PH1.2 aggregate through the ORM and exercises the `plan_lock` per-type CHECK constraints, the `plan_version_date` partial-unique, and the `reliability_score` `NULLS NOT DISTINCT` unique against real Postgres. - `mypy --strict` and `ruff` clean; full backend suite green (142 passed, 5 skipped — the skips are the pre-existing Keycloak compose-stack smoke tests, unrelated). ### Seam note (mirroring PH1.1's issue #57 precedent) Two pre-existing tests outside this ticket's owned paths encoded "not there yet" assumptions that this migration resolves by design, so I fixed them directly rather than leave a regression, and I'm opening a seam-request issue for traceability: - `tests/integration/db/test_migrations.py`: the PH1.1 test that downgraded `-1` from `head` to check `0002`'s round-trip now needs to target `0002` explicitly, since `head` is `0003`. Added the `0003` equivalent alongside it. - `tests/integration/api/test_metrics_db.py`: PH6.6's `/metrics` `solver_run` gauges (`api/metrics.py`) were tested against `solver_run` not existing (`has_table(...)` returns `False`); now that it exists, rewrote that test to cover the empty-table (headers + zero gauge, no per-status line) and populated-table rendering instead. `api/metrics.py` itself needed no change — its docstring already anticipated this ("once the migration lands these lines start appearing with no further change here"). Neither `db/audit.py`, `api/main.py`, nor any other sealed/owned file was touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
Migration 0003 creates every planning/execution table from docs/08-data-model.md:
solver_run, plan_version, plan_version_date, assignment, unassigned_job, plan_lock,
validation_finding, service_feedback, reliability_score, notification, with matching
typed SQLAlchemy mapped classes following the _mixins.py/_types.py/_enum.py pattern
from PH1.1 (0002).

- assignment: all documented indexes (plan_version_id+shift_id+sequence_no, job_id,
  shift_id+status, shift_id+planned_start, plan_version_id+review).
- plan_lock: AuditColumns + Auditable (status transitions active/cleared/expired,
  like job/shift/site_suitability), with one named CHECK constraint per lock type
  enforcing its required columns per docs/08-data-model.md (pin needs job+shift+window,
  reject_window needs job+site+date+range per E21's not-on-window-id matching, etc.).
- plan_version_date: composite PK (plan_version_id, date) + partial unique index on
  date where is_active, for "exactly one active version per date".
- reliability_score: unique (site_id, vehicle_id, weekday) with NULLS NOT DISTINCT.
- Append-only tables (solver_run, plan_version, assignment, plan_version_date,
  unassigned_job, service_feedback, validation_finding) have no updated_at/deleted_at
  and no Auditable mixin, per hard rule #1 and ingest_record's PH1.1 precedent.

Also, mirroring PH1.1's seam-request precedent (issue #57): fixes two pre-existing
tests whose premises this migration invalidates by design —
- tests/integration/db/test_migrations.py: the old "downgrade -1 from head round-trips
  0002" test now targets 0002 explicitly rather than head (head is 0003 now); adds the
  equivalent test for 0003.
- tests/integration/api/test_metrics_db.py: PH6.6's /metrics solver_run gauges were
  tested against solver_run not existing yet ("PH1.2/T-E ... not on main yet"); now
  that it does, the test is rewritten to cover the empty-table and populated-table
  rendering instead. Seam-request issue opened for traceability.

Closes #10

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
bart merged commit 0c7694ab2b into main 2026-09-11 12:04:48 +00:00
bart deleted branch feature/db-schema-planning 2026-09-11 12:04:48 +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!66
No description provided.