From fb75e23ae50a3e5e285e378ce6c0add12a1fe872 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 18:30:00 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(api):=20PROOF9=20REST=20API=20router?= =?UTF-8?q?=20=E2=80=94=20/api/v2/proof=20endpoints=20(#456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proof_v2.py router exposing PROOF9 quality system via REST API: - POST /api/v2/proof/requirements — capture_requirement() (classify glitch → obligations → stubs) - GET /api/v2/proof/requirements — list_requirements() with optional ?status= filter - GET /api/v2/proof/requirements/{id} — get_requirement() - POST /api/v2/proof/run — run_proof() with full/gate_filter params - POST /api/v2/proof/requirements/{id}/waive — waive_requirement() - GET /api/v2/proof/status — aggregated counts + full requirement list - GET /api/v2/proof/requirements/{id}/evidence — list_evidence() Router registered in server.py; OpenAPI tag "proof-v2" added to /docs. 35 new integration tests in tests/ui/test_proof_v2.py (all passing). Thin-adapter pattern: all logic delegates to codeframe.core.proof.*. --- codeframe/ui/routers/proof_v2.py | 427 +++++++++++++++++++++++++++ codeframe/ui/server.py | 6 + tests/ui/test_proof_v2.py | 492 +++++++++++++++++++++++++++++++ 3 files changed, 925 insertions(+) create mode 100644 codeframe/ui/routers/proof_v2.py create mode 100644 tests/ui/test_proof_v2.py diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py new file mode 100644 index 00000000..571df2a0 --- /dev/null +++ b/codeframe/ui/routers/proof_v2.py @@ -0,0 +1,427 @@ +"""PROOF9 REST API router — thin adapter over codeframe.core.proof. + +Maps HTTP endpoints to core proof functions (capture, list, get, run, waive, +status, evidence). No business logic lives here. + +Routes: + POST /api/v2/proof/requirements capture_requirement() + GET /api/v2/proof/requirements list_requirements() + GET /api/v2/proof/requirements/{req_id} get_requirement() + POST /api/v2/proof/run run_proof() + POST /api/v2/proof/requirements/{req_id}/waive waive_requirement() + GET /api/v2/proof/status aggregated status + GET /api/v2/proof/requirements/{req_id}/evidence list_evidence() +""" + +import logging +from datetime import date +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from codeframe.core.proof.capture import capture_requirement +from codeframe.core.proof.ledger import ( + get_requirement, + list_evidence, + list_requirements, + waive_requirement, +) +from codeframe.core.proof.models import ( + Gate, + ReqStatus, + Severity, + Source, + Waiver, +) +from codeframe.core.proof.runner import run_proof +from codeframe.core.workspace import Workspace +from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard +from codeframe.ui.dependencies import get_v2_workspace +from codeframe.ui.response_models import ErrorCodes, api_error + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v2/proof", tags=["proof-v2"]) + + +# ============================================================================ +# Request / Response Models +# ============================================================================ + + +class CaptureRequirementRequest(BaseModel): + """Request body for capturing a requirement from a glitch.""" + + title: str = Field(..., min_length=1, description="Short title of the glitch") + description: str = Field(..., min_length=1, description="Detailed description for glitch classification") + where: str = Field(..., min_length=1, description="Location (file, route, API, tag) where glitch occurred") + severity: Severity = Field(..., description="Severity: critical, high, medium, low") + source: Source = Field(..., description="Source: production, qa, dogfooding, monitoring, user_report") + created_by: str = Field(default="human", description="Who captured this requirement") + source_issue: Optional[str] = Field(default=None, description="External issue reference (e.g. GH-123)") + + +class WaiveRequirementRequest(BaseModel): + """Request body for waiving a requirement.""" + + reason: str = Field(..., min_length=1, description="Why this requirement is being waived") + expires: Optional[date] = Field(default=None, description="ISO date when waiver expires (e.g. 2026-06-01)") + manual_checklist: list[str] = Field(default_factory=list, description="Manual verification steps") + approved_by: str = Field(default="", description="Who approved this waiver") + + +class RunProofRequest(BaseModel): + """Request body for running proof obligations.""" + + full: bool = Field(default=False, description="Run ALL obligations regardless of scope") + gate: Optional[Gate] = Field(default=None, description="Run only this gate (unit, sec, contract, etc.)") + + +class ObligationOut(BaseModel): + """Serialized proof obligation.""" + + gate: str + status: str + + +class EvidenceRuleOut(BaseModel): + """Serialized evidence rule.""" + + test_id: str + must_pass: bool + + +class WaiverOut(BaseModel): + """Serialized waiver.""" + + reason: str + expires: Optional[str] + manual_checklist: list[str] + approved_by: str + + +class RequirementResponse(BaseModel): + """Full requirement response.""" + + id: str + title: str + description: str + severity: str + source: str + status: str + glitch_type: Optional[str] + obligations: list[ObligationOut] + evidence_rules: list[EvidenceRuleOut] + waiver: Optional[WaiverOut] + created_at: Optional[str] + satisfied_at: Optional[str] + created_by: str + source_issue: Optional[str] + related_reqs: list[str] + + +class CaptureRequirementResponse(RequirementResponse): + """Capture response adds stubs_count.""" + + stubs_count: int = Field(description="Number of test stub files generated") + + +class RequirementListResponse(BaseModel): + """Response for list/filter endpoints.""" + + requirements: list[RequirementResponse] + total: int + by_status: dict[str, int] + + +class RunProofResponse(BaseModel): + """Response for POST /run.""" + + success: bool + run_id: str + results: dict[str, list[dict[str, Any]]] + message: str + + +class ProofStatusResponse(BaseModel): + """Aggregated proof status response.""" + + total: int + open: int + satisfied: int + waived: int + requirements: list[RequirementResponse] + + +class EvidenceResponse(BaseModel): + """Serialized evidence record.""" + + req_id: str + gate: str + satisfied: bool + artifact_path: str + artifact_checksum: str + timestamp: str + run_id: str + + +# ============================================================================ +# Helper +# ============================================================================ + + +def _req_to_response(req) -> RequirementResponse: + """Convert a core Requirement dataclass to RequirementResponse.""" + return RequirementResponse( + id=req.id, + title=req.title, + description=req.description, + severity=req.severity.value, + source=req.source.value, + status=req.status.value, + glitch_type=req.glitch_type.value if req.glitch_type else None, + obligations=[ + ObligationOut(gate=o.gate.value, status=o.status) + for o in req.obligations + ], + evidence_rules=[ + EvidenceRuleOut(test_id=r.test_id, must_pass=r.must_pass) + for r in req.evidence_rules + ], + waiver=WaiverOut( + reason=req.waiver.reason, + expires=req.waiver.expires.isoformat() if req.waiver.expires else None, + manual_checklist=req.waiver.manual_checklist, + approved_by=req.waiver.approved_by, + ) if req.waiver else None, + created_at=req.created_at.isoformat() if req.created_at else None, + satisfied_at=req.satisfied_at.isoformat() if req.satisfied_at else None, + created_by=req.created_by, + source_issue=req.source_issue, + related_reqs=req.related_reqs, + ) + + +def _count_by_status(reqs) -> dict[str, int]: + """Aggregate requirement counts by status value.""" + counts: dict[str, int] = {s.value: 0 for s in ReqStatus} + for req in reqs: + counts[req.status.value] = counts.get(req.status.value, 0) + 1 + return counts + + +# ============================================================================ +# Endpoints +# ============================================================================ + + +@router.post("/requirements", response_model=CaptureRequirementResponse, status_code=201) +@rate_limit_standard() +async def capture_requirement_endpoint( + request: Request, + body: CaptureRequirementRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> CaptureRequirementResponse: + """Capture a requirement from a glitch report. + + Classifies the glitch, derives proof obligations, generates test stubs, + and persists the requirement to the ledger. + """ + try: + req, stubs = capture_requirement( + workspace, + title=body.title, + description=body.description, + where=body.where, + severity=body.severity, + source=body.source, + created_by=body.created_by, + source_issue=body.source_issue, + ) + resp = _req_to_response(req) + return CaptureRequirementResponse(**resp.model_dump(), stubs_count=len(stubs)) + except Exception as e: + logger.error("Failed to capture requirement: %s", e, exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error("Failed to capture requirement", ErrorCodes.EXECUTION_FAILED, str(e)), + ) + + +@router.get("/requirements", response_model=RequirementListResponse) +@rate_limit_standard() +async def list_requirements_endpoint( + request: Request, + status: Optional[str] = Query(None, description="Filter by status: open, satisfied, waived"), + workspace: Workspace = Depends(get_v2_workspace), +) -> RequirementListResponse: + """List all requirements, optionally filtered by status.""" + status_filter = None + if status: + try: + status_filter = ReqStatus(status.lower()) + except ValueError: + raise HTTPException( + status_code=400, + detail=api_error( + f"Invalid status: {status}", + ErrorCodes.VALIDATION_ERROR, + f"Valid values: {[s.value for s in ReqStatus]}", + ), + ) + + reqs = list_requirements(workspace, status=status_filter) + all_reqs = list_requirements(workspace) if status_filter else reqs + + return RequirementListResponse( + requirements=[_req_to_response(r) for r in reqs], + total=len(reqs), + by_status=_count_by_status(all_reqs), + ) + + +@router.get("/requirements/{req_id}", response_model=RequirementResponse) +@rate_limit_standard() +async def get_requirement_endpoint( + request: Request, + req_id: str, + workspace: Workspace = Depends(get_v2_workspace), +) -> RequirementResponse: + """Get a single requirement by ID.""" + req = get_requirement(workspace, req_id) + if not req: + raise HTTPException( + status_code=404, + detail=api_error( + f"Requirement not found: {req_id}", + ErrorCodes.NOT_FOUND, + f"No requirement with id {req_id}", + ), + ) + return _req_to_response(req) + + +@router.post("/run", response_model=RunProofResponse) +@rate_limit_ai() +async def run_proof_endpoint( + request: Request, + body: RunProofRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> RunProofResponse: + """Execute proof obligations and collect evidence. + + Runs gate checks (pytest, ruff, etc.) for open requirements and records + evidence artifacts. Use full=True to run all obligations regardless of + changed scope. + """ + try: + results = run_proof( + workspace, + full=body.full, + gate_filter=body.gate, + ) + # Serialize: dict[req_id → list[tuple[Gate, bool]]] → JSON-safe + serialized = { + req_id: [{"gate": gate.value, "satisfied": satisfied} for gate, satisfied in gate_results] + for req_id, gate_results in results.items() + } + # Derive a run_id from the results or generate one + import uuid + run_id = str(uuid.uuid4())[:8] + + return RunProofResponse( + success=True, + run_id=run_id, + results=serialized, + message=f"Proof run complete: {len(results)} requirement(s) evaluated.", + ) + except Exception as e: + logger.error("Proof run failed: %s", e, exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error("Proof run failed", ErrorCodes.EXECUTION_FAILED, str(e)), + ) + + +@router.post("/requirements/{req_id}/waive", response_model=RequirementResponse) +@rate_limit_standard() +async def waive_requirement_endpoint( + request: Request, + req_id: str, + body: WaiveRequirementRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> RequirementResponse: + """Waive a requirement with a reason and optional expiry date.""" + existing = get_requirement(workspace, req_id) + if not existing: + raise HTTPException( + status_code=404, + detail=api_error( + f"Requirement not found: {req_id}", + ErrorCodes.NOT_FOUND, + f"No requirement with id {req_id}", + ), + ) + + waiver = Waiver( + reason=body.reason, + expires=body.expires, + manual_checklist=body.manual_checklist, + approved_by=body.approved_by, + ) + updated = waive_requirement(workspace, req_id, waiver) + return _req_to_response(updated) + + +@router.get("/status", response_model=ProofStatusResponse) +@rate_limit_standard() +async def proof_status_endpoint( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), +) -> ProofStatusResponse: + """Get aggregated proof status: totals by status and full requirement list.""" + reqs = list_requirements(workspace) + counts = _count_by_status(reqs) + + return ProofStatusResponse( + total=len(reqs), + open=counts.get("open", 0), + satisfied=counts.get("satisfied", 0), + waived=counts.get("waived", 0), + requirements=[_req_to_response(r) for r in reqs], + ) + + +@router.get("/requirements/{req_id}/evidence", response_model=list[EvidenceResponse]) +@rate_limit_standard() +async def list_evidence_endpoint( + request: Request, + req_id: str, + workspace: Workspace = Depends(get_v2_workspace), +) -> list[EvidenceResponse]: + """List all evidence records for a requirement.""" + req = get_requirement(workspace, req_id) + if not req: + raise HTTPException( + status_code=404, + detail=api_error( + f"Requirement not found: {req_id}", + ErrorCodes.NOT_FOUND, + f"No requirement with id {req_id}", + ), + ) + + evidence = list_evidence(workspace, req_id) + return [ + EvidenceResponse( + req_id=e.req_id, + gate=e.gate.value, + satisfied=e.satisfied, + artifact_path=e.artifact_path, + artifact_checksum=e.artifact_checksum, + timestamp=e.timestamp.isoformat(), + run_id=e.run_id, + ) + for e in evidence + ] diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index ced9f3da..8b6163cd 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -30,6 +30,7 @@ git_v2, pr_v2, prd_v2, + proof_v2, review_v2, schedule_v2, streaming_v2, @@ -238,6 +239,10 @@ async def lifespan(app: FastAPI): "name": "auth", "description": "Authentication and authorization - login, logout, API keys, and session management.", }, + { + "name": "proof-v2", + "description": "PROOF9 quality system — capture requirements from glitches, run proof obligations, manage waivers, and query evidence.", + }, ] OPENAPI_DESCRIPTION = """ @@ -472,6 +477,7 @@ async def test_broadcast(message: dict, project_id: int = None): app.include_router(git_v2.router) # /api/v2/git app.include_router(pr_v2.router) # /api/v2/pr app.include_router(prd_v2.router) # /api/v2/prd +app.include_router(proof_v2.router) # /api/v2/proof app.include_router(review_v2.router) # /api/v2/review app.include_router(schedule_v2.router) # /api/v2/schedule app.include_router(streaming_v2.router) # /api/v2/tasks/{id}/stream (SSE) diff --git a/tests/ui/test_proof_v2.py b/tests/ui/test_proof_v2.py new file mode 100644 index 00000000..5bcb22f7 --- /dev/null +++ b/tests/ui/test_proof_v2.py @@ -0,0 +1,492 @@ +"""Integration tests for proof_v2 router (PROOF9 REST API). + +Verifies that proof_v2 router: +1. Properly delegates to core/proof/* modules +2. Returns correct HTTP status codes and response shapes +3. Handles invalid inputs with structured errors +4. Follows v2 API patterns (workspace-based, standard response format) + +Tests use FastAPI TestClient with dependency overrides — no server required. +""" + +import shutil +import tempfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# Mark all tests in this module as v2 +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def test_workspace(): + """Create a temporary workspace for testing.""" + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_workspace" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + """Create a FastAPI TestClient with proof_v2 router and workspace override.""" + from codeframe.ui.routers import proof_v2 + from codeframe.ui.dependencies import get_v2_workspace + + app = FastAPI() + app.include_router(proof_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + + client = TestClient(app) + client.workspace = test_workspace + return client + + +# ============================================================================ +# POST /api/v2/proof/requirements — capture requirement +# ============================================================================ + + +class TestCaptureRequirement: + """Tests for POST /api/v2/proof/requirements.""" + + def _valid_body(self, **overrides): + base = { + "title": "Login fails silently on bad token", + "description": "Auth logic bug: token expiry not checked before redirect", + "where": "codeframe/auth/dependencies.py", + "severity": "high", + "source": "production", + } + base.update(overrides) + return base + + def test_capture_returns_201(self, test_client): + """Capture requirement returns 201 on valid input.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(), + ) + assert response.status_code == 201 + + def test_capture_returns_requirement_id(self, test_client): + """Captured requirement has REQ-#### ID.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(), + ) + data = response.json() + assert data["id"].startswith("REQ-") + + def test_capture_response_shape(self, test_client): + """Response includes all required fields.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(), + ) + data = response.json() + for field in ["id", "title", "description", "severity", "source", "status", + "obligations", "evidence_rules", "created_at", "stubs_count"]: + assert field in data, f"Missing field: {field}" + + def test_capture_with_optional_fields(self, test_client): + """Optional fields (created_by, source_issue) are accepted.""" + body = self._valid_body( + created_by="ci-bot", + source_issue="GH-123", + ) + response = test_client.post("/api/v2/proof/requirements", json=body) + assert response.status_code == 201 + data = response.json() + assert data["created_by"] == "ci-bot" + assert data["source_issue"] == "GH-123" + + def test_capture_status_defaults_to_open(self, test_client): + """Newly captured requirement has status 'open'.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(), + ) + assert response.json()["status"] == "open" + + def test_capture_missing_title_returns_422(self, test_client): + """Missing required field returns 422.""" + body = self._valid_body() + del body["title"] + response = test_client.post("/api/v2/proof/requirements", json=body) + assert response.status_code == 422 + + def test_capture_invalid_severity_returns_422(self, test_client): + """Invalid severity enum returns 422.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(severity="extreme"), + ) + assert response.status_code == 422 + + def test_capture_invalid_source_returns_422(self, test_client): + """Invalid source enum returns 422.""" + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(source="unknown"), + ) + assert response.status_code == 422 + + def test_capture_persists_to_core(self, test_client): + """Captured requirement is retrievable via core ledger.""" + from codeframe.core.proof.ledger import list_requirements + + response = test_client.post( + "/api/v2/proof/requirements", + json=self._valid_body(), + ) + req_id = response.json()["id"] + reqs = list_requirements(test_client.workspace) + assert any(r.id == req_id for r in reqs) + + +# ============================================================================ +# GET /api/v2/proof/requirements — list requirements +# ============================================================================ + + +class TestListRequirements: + """Tests for GET /api/v2/proof/requirements.""" + + def _capture(self, test_client, **overrides): + body = { + "title": "Test requirement", + "description": "A test bug", + "where": "core/tasks.py", + "severity": "medium", + "source": "qa", + } + body.update(overrides) + return test_client.post("/api/v2/proof/requirements", json=body) + + def test_list_empty(self, test_client): + """Returns empty list when no requirements exist.""" + response = test_client.get("/api/v2/proof/requirements") + assert response.status_code == 200 + data = response.json() + assert data["requirements"] == [] + assert data["total"] == 0 + assert "by_status" in data + + def test_list_returns_captured_requirements(self, test_client): + """Returns requirements after capture.""" + self._capture(test_client) + response = test_client.get("/api/v2/proof/requirements") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["requirements"]) == 1 + + def test_list_filter_by_valid_status(self, test_client): + """?status=open returns only open requirements.""" + self._capture(test_client) + response = test_client.get("/api/v2/proof/requirements?status=open") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + for req in data["requirements"]: + assert req["status"] == "open" + + def test_list_filter_by_invalid_status(self, test_client): + """?status=invalid returns 400.""" + response = test_client.get("/api/v2/proof/requirements?status=invalid") + assert response.status_code == 400 + detail = response.json()["detail"] + assert "code" in detail + + def test_list_by_status_counts_all_statuses(self, test_client): + """by_status dict includes all known statuses.""" + response = test_client.get("/api/v2/proof/requirements") + data = response.json() + assert "open" in data["by_status"] + + +# ============================================================================ +# GET /api/v2/proof/requirements/{req_id} — get single requirement +# ============================================================================ + + +class TestGetRequirement: + """Tests for GET /api/v2/proof/requirements/{req_id}.""" + + def _capture(self, test_client): + return test_client.post( + "/api/v2/proof/requirements", + json={ + "title": "Get test", + "description": "Testing get endpoint", + "where": "core/tasks.py", + "severity": "low", + "source": "dogfooding", + }, + ) + + def test_get_existing_requirement(self, test_client): + """Returns requirement by ID.""" + req_id = self._capture(test_client).json()["id"] + response = test_client.get(f"/api/v2/proof/requirements/{req_id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == req_id + assert data["title"] == "Get test" + + def test_get_nonexistent_returns_404(self, test_client): + """Returns 404 for unknown ID.""" + response = test_client.get("/api/v2/proof/requirements/REQ-9999") + assert response.status_code == 404 + detail = response.json()["detail"] + assert "code" in detail + + def test_get_returns_full_shape(self, test_client): + """GET response includes obligations and evidence_rules.""" + req_id = self._capture(test_client).json()["id"] + response = test_client.get(f"/api/v2/proof/requirements/{req_id}") + data = response.json() + assert "obligations" in data + assert "evidence_rules" in data + assert isinstance(data["obligations"], list) + + +# ============================================================================ +# POST /api/v2/proof/run — run proof obligations +# ============================================================================ + + +class TestRunProof: + """Tests for POST /api/v2/proof/run.""" + + def test_run_empty_returns_success(self, test_client): + """Run with no requirements returns success with empty results.""" + response = test_client.post("/api/v2/proof/run", json={}) + assert response.status_code == 200 + data = response.json() + assert "success" in data + assert "run_id" in data + assert "results" in data + + def test_run_returns_run_id(self, test_client): + """Response includes a run_id.""" + response = test_client.post("/api/v2/proof/run", json={}) + assert response.json()["run_id"] is not None + + def test_run_full_flag_accepted(self, test_client): + """full=True is accepted.""" + response = test_client.post("/api/v2/proof/run", json={"full": True}) + assert response.status_code == 200 + + def test_run_with_gate_filter(self, test_client): + """gate filter is accepted.""" + response = test_client.post("/api/v2/proof/run", json={"gate": "unit"}) + assert response.status_code == 200 + + def test_run_invalid_gate_returns_422(self, test_client): + """Invalid gate enum returns 422.""" + response = test_client.post("/api/v2/proof/run", json={"gate": "not_a_gate"}) + assert response.status_code == 422 + + def test_run_results_shape(self, test_client): + """results is a dict mapping req_id to list of gate results.""" + # Capture a requirement first so there's something to run + test_client.post( + "/api/v2/proof/requirements", + json={ + "title": "Run test req", + "description": "A logic bug for run test", + "where": "core/tasks.py", + "severity": "medium", + "source": "qa", + }, + ) + response = test_client.post("/api/v2/proof/run", json={"full": True}) + data = response.json() + assert isinstance(data["results"], dict) + + +# ============================================================================ +# POST /api/v2/proof/requirements/{req_id}/waive — waive requirement +# ============================================================================ + + +class TestWaiveRequirement: + """Tests for POST /api/v2/proof/requirements/{req_id}/waive.""" + + def _capture(self, test_client): + return test_client.post( + "/api/v2/proof/requirements", + json={ + "title": "Waive test", + "description": "Testing waiver endpoint", + "where": "core/tasks.py", + "severity": "low", + "source": "qa", + }, + ).json()["id"] + + def test_waive_returns_200(self, test_client): + """Waiving a requirement returns 200.""" + req_id = self._capture(test_client) + response = test_client.post( + f"/api/v2/proof/requirements/{req_id}/waive", + json={"reason": "Deferred to next sprint", "approved_by": "team-lead"}, + ) + assert response.status_code == 200 + + def test_waive_changes_status(self, test_client): + """Status changes to 'waived' after waiving.""" + req_id = self._capture(test_client) + response = test_client.post( + f"/api/v2/proof/requirements/{req_id}/waive", + json={"reason": "Deferred"}, + ) + assert response.json()["status"] == "waived" + + def test_waive_with_expiry(self, test_client): + """Waiver with expires date is accepted.""" + req_id = self._capture(test_client) + response = test_client.post( + f"/api/v2/proof/requirements/{req_id}/waive", + json={"reason": "Short-term waiver", "expires": "2026-06-01"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["waiver"] is not None + assert data["waiver"]["reason"] == "Short-term waiver" + + def test_waive_nonexistent_returns_404(self, test_client): + """Waiving unknown requirement returns 404.""" + response = test_client.post( + "/api/v2/proof/requirements/REQ-9999/waive", + json={"reason": "Does not exist"}, + ) + assert response.status_code == 404 + + def test_waive_missing_reason_returns_422(self, test_client): + """Missing reason returns 422.""" + req_id = self._capture(test_client) + response = test_client.post( + f"/api/v2/proof/requirements/{req_id}/waive", + json={}, + ) + assert response.status_code == 422 + + +# ============================================================================ +# GET /api/v2/proof/status — aggregated proof status +# ============================================================================ + + +class TestProofStatus: + """Tests for GET /api/v2/proof/status.""" + + def test_status_empty(self, test_client): + """Status returns zeros when no requirements.""" + response = test_client.get("/api/v2/proof/status") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert "open" in data + assert "satisfied" in data + assert "waived" in data + + def test_status_counts_requirements(self, test_client): + """Status counts captured requirements correctly.""" + test_client.post( + "/api/v2/proof/requirements", + json={ + "title": "Status count test", + "description": "A bug for status test", + "where": "core/tasks.py", + "severity": "medium", + "source": "qa", + }, + ) + response = test_client.get("/api/v2/proof/status") + data = response.json() + assert data["total"] == 1 + assert data["open"] == 1 + assert data["satisfied"] == 0 + assert data["waived"] == 0 + + def test_status_includes_requirements_list(self, test_client): + """Response includes full requirements list.""" + response = test_client.get("/api/v2/proof/status") + data = response.json() + assert "requirements" in data + assert isinstance(data["requirements"], list) + + +# ============================================================================ +# GET /api/v2/proof/requirements/{req_id}/evidence — list evidence +# ============================================================================ + + +class TestListEvidence: + """Tests for GET /api/v2/proof/requirements/{req_id}/evidence.""" + + def _capture(self, test_client): + return test_client.post( + "/api/v2/proof/requirements", + json={ + "title": "Evidence test", + "description": "A bug for evidence test", + "where": "core/tasks.py", + "severity": "low", + "source": "qa", + }, + ).json()["id"] + + def test_evidence_empty_for_new_requirement(self, test_client): + """New requirement has no evidence.""" + req_id = self._capture(test_client) + response = test_client.get(f"/api/v2/proof/requirements/{req_id}/evidence") + assert response.status_code == 200 + assert response.json() == [] + + def test_evidence_nonexistent_returns_404(self, test_client): + """Evidence for unknown requirement returns 404.""" + response = test_client.get("/api/v2/proof/requirements/REQ-9999/evidence") + assert response.status_code == 404 + + +# ============================================================================ +# Error Response Format +# ============================================================================ + + +class TestErrorResponses: + """Verify all v2-style structured error responses.""" + + def test_404_format(self, test_client): + """404 errors include error, code, detail fields.""" + response = test_client.get("/api/v2/proof/requirements/REQ-9999") + assert response.status_code == 404 + detail = response.json()["detail"] + assert "error" in detail + assert "code" in detail + + def test_400_format(self, test_client): + """400 errors include error and code fields.""" + response = test_client.get("/api/v2/proof/requirements?status=invalid") + assert response.status_code == 400 + detail = response.json()["detail"] + assert "error" in detail + assert "code" in detail From 422c63c4fcd0bcd2e7ac910a207995e7e81f3b79 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 18:43:48 -0700 Subject: [PATCH 2/3] fix: generate run_id before run_proof() so response ID matches evidence records --- codeframe/ui/routers/proof_v2.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py index 571df2a0..7eecdbd8 100644 --- a/codeframe/ui/routers/proof_v2.py +++ b/codeframe/ui/routers/proof_v2.py @@ -14,6 +14,7 @@ """ import logging +import uuid from datetime import date from typing import Any, Optional @@ -316,19 +317,19 @@ async def run_proof_endpoint( changed scope. """ try: + # Generate run_id before calling run_proof so the response ID matches evidence records + run_id = str(uuid.uuid4())[:8] results = run_proof( workspace, full=body.full, gate_filter=body.gate, + run_id=run_id, ) # Serialize: dict[req_id → list[tuple[Gate, bool]]] → JSON-safe serialized = { req_id: [{"gate": gate.value, "satisfied": satisfied} for gate, satisfied in gate_results] for req_id, gate_results in results.items() } - # Derive a run_id from the results or generate one - import uuid - run_id = str(uuid.uuid4())[:8] return RunProofResponse( success=True, From 129052b38575b011b6629c06cb433bd2388a4070 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 18:55:16 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20update=20CLAUDE.md=20=E2=80=94=20pr?= =?UTF-8?q?oof=5Fv2=20router=20added,=20router=20count=2015=E2=86=9216?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b0fbc38..078c3906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ The next major architectural work is the **Agent Adapter Architecture** (#408): - **GitHub PR workflow**: `cf pr create/status/checks/merge` for PR management - **Task self-diagnosis**: `cf work diagnose ` analyzes failed tasks - **70+ integration tests**: Comprehensive CLI test coverage -- **REST API**: Full v2 API with 15 router modules (see Phase 2 below) +- **REST API**: Full v2 API with 16 router modules (see Phase 2 below) - **API authentication**: API key auth with scopes (read/write/admin) - **Rate limiting**: Configurable per-endpoint rate limits - **Real-time streaming**: SSE for task execution events @@ -152,7 +152,7 @@ codeframe/ │ ├── batches_v2.py # Batch execution │ ├── streaming_v2.py # SSE event streaming │ ├── api_key_v2.py # API key management -│ └── ... # 15 router modules total +│ └── ... # 16 router modules total ├── lib/ # Shared utilities │ ├── rate_limiter.py # SlowAPI rate limiting │ └── audit_logger.py # Request audit logging @@ -560,7 +560,7 @@ Default execution engine switched from plan-based to **ReAct (Reasoning + Acting ### Phase 2 Complete: Server Layer (2026-02-03) **Phase 2 deliverables completed:** -- ✅ Server audit and refactor (#322) - 15 v2 routers following thin adapter pattern +- ✅ Server audit and refactor (#322) - 16 v2 routers following thin adapter pattern - ✅ API key authentication (#326) - Scopes: read/write/admin - ✅ Rate limiting (#327) - Configurable per-endpoint with Redis support - ✅ Real-time SSE streaming (#328) - `/api/v2/tasks/{id}/stream` @@ -576,7 +576,7 @@ CLI (typer) ─┬── core.* ─── adapters.* Server (fastapi) ─┘ ``` -**V2 Router Modules** (15 total): +**V2 Router Modules** (16 total): | Router | Endpoints | Purpose | |--------|-----------|---------| | `blockers_v2` | 5 | Blocker CRUD | @@ -594,6 +594,7 @@ Server (fastapi) ─┘ | `review_v2` | 2 | Code review | | `pr_v2` | 5 | GitHub PR workflow | | `environment_v2` | 4 | Tool detection | +| `proof_v2` | 7 | PROOF9 quality gates + requirements | **API Authentication**: ```bash