Goal
Close two related v0.1 gaps that the External Results smoke workflow surfaced:
- There is no
POST /api/v1/projects (or any projects API) on main — only the underlying Project model. The smoke workflow's bootstrap.py falls back to a synthetic UUID every run as a result.
POST /api/v1/external-results/session accepts any project_id UUID without FK-validating against the projects table. The spec advertises a 400 session.project_not_found error code, but the path that should raise it does not exist yet (see docs/specs/external_results_v1.md § c.1, "Error codes" table).
Fixing #1 and #2 together turns the smoke workflow into a real end-to-end check: the smoke run will create a real project row, and the session-create call will validate against it.
Why this is v0.2, not v0.1
The External Results v0.1 contract chain (#291 and its subs) explicitly froze surface and merged a complete, deliverable v0.1. Adding a projects API and FK validation are surface widenings — appropriate for v0.2, not retrofits to v0.1.
Tracked here so the smoke workflow's TODO comments and the spec's aspirational-but-not-implemented 400 session.project_not_found row get a real home.
Discovered during
Scope
Backend
backend/app/api/projects.py — new router with POST /projects, GET /projects, GET /projects/{id}, PATCH /projects/{id}. Skip DELETE for v0.2 (separate concern: cascades).
backend/app/schemas/project.py — ProjectCreate, ProjectResponse, ProjectUpdate. Match the existing Project model's columns.
backend/app/crud/project.py — standard CRUD helpers, async, mirroring crud/requirement.py.
backend/app/main.py — register the new router under API_V1_PREFIX.
Auth
External Results FK validation
app.crud.external_results.create_session — before insert, verify project_id resolves to an existing row. If not, raise ValueError with {"code": "session.project_not_found", "message": "Project <uuid> does not exist.", "details": null}.
- The router maps that to a
400 Bad Request per the existing spec table.
- Test: create a session with a random UUID → 400; with a real project → 201.
Smoke workflow updates (after this lands)
- Remove the synthetic-UUID fallback in
scripts/smoke/bootstrap.py.
- Update the inline comment to point at this issue's resolution.
- Bump the smoke fixture pin if any frameworks-side change is needed (likely none — the reporter doesn't care whether the project is real, only that the API returns a project_id).
Tests
- Standard CRUD tests for projects (mirror
test_requirements.py).
test_external_results_session_project_fk.py — explicit test for the new 400 code path.
- Smoke-bootstrap regression test — assert
bootstrap.py no longer takes the fallback path.
Spec doc
- Already declares
400 session.project_not_found in section c.1 — update the surrounding prose to indicate it's now enforced (was aspirational).
- Add a one-paragraph "Project lifecycle" section (or similar) under endpoints documentation.
Out of scope
- Project membership / per-project authorization (separate v0.2 epic if we want it).
- Soft-delete on projects.
- Cross-project resource visibility — projects table only, no project_id columns added to other tables.
- Changes to the External Results contract surface beyond the FK enforcement —
external_results_v1 stays frozen.
Acceptance criteria
References
Goal
Close two related v0.1 gaps that the External Results smoke workflow surfaced:
POST /api/v1/projects(or any projects API) onmain— only the underlyingProjectmodel. The smoke workflow'sbootstrap.pyfalls back to a synthetic UUID every run as a result.POST /api/v1/external-results/sessionaccepts anyproject_idUUID without FK-validating against the projects table. The spec advertises a400 session.project_not_founderror code, but the path that should raise it does not exist yet (seedocs/specs/external_results_v1.md§ c.1, "Error codes" table).Fixing #1 and #2 together turns the smoke workflow into a real end-to-end check: the smoke run will create a real project row, and the session-create call will validate against it.
Why this is v0.2, not v0.1
The External Results v0.1 contract chain (#291 and its subs) explicitly froze surface and merged a complete, deliverable v0.1. Adding a projects API and FK validation are surface widenings — appropriate for v0.2, not retrofits to v0.1.
Tracked here so the smoke workflow's TODO comments and the spec's aspirational-but-not-implemented
400 session.project_not_foundrow get a real home.Discovered during
bootstrap.pyalready includes a fallback comment pointing hereScope
Backend
backend/app/api/projects.py— new router withPOST /projects,GET /projects,GET /projects/{id},PATCH /projects/{id}. Skip DELETE for v0.2 (separate concern: cascades).backend/app/schemas/project.py—ProjectCreate,ProjectResponse,ProjectUpdate. Match the existingProjectmodel's columns.backend/app/crud/project.py— standard CRUD helpers, async, mirroringcrud/requirement.py.backend/app/main.py— register the new router underAPI_V1_PREFIX.Auth
POST,PATCH) requirerevieweroradmin(mirror requirements API).External Results FK validation
app.crud.external_results.create_session— before insert, verifyproject_idresolves to an existing row. If not, raiseValueErrorwith{"code": "session.project_not_found", "message": "Project <uuid> does not exist.", "details": null}.400 Bad Requestper the existing spec table.Smoke workflow updates (after this lands)
scripts/smoke/bootstrap.py.Tests
test_requirements.py).test_external_results_session_project_fk.py— explicit test for the new 400 code path.bootstrap.pyno longer takes the fallback path.Spec doc
400 session.project_not_foundin section c.1 — update the surrounding prose to indicate it's now enforced (was aspirational).Out of scope
external_results_v1stays frozen.Acceptance criteria
POST /api/v1/projectsreturns 201 with project response.POST /api/v1/external-results/sessionreturns400 session.project_not_foundfor unknown project_id.bootstrap.pycreates a real project (no fallback path remaining).References
400 session.project_not_founderror code that's currently aspirational