# [DEF:backend.src.api.routes.clean_release:Module] # @TIER: STANDARD # @SEMANTICS: api, clean-release, candidate-preparation, compliance # @PURPOSE: Expose clean release endpoints for candidate preparation and subsequent compliance flow. # @LAYER: API # @RELATION: DEPENDS_ON -> backend.src.dependencies.get_clean_release_repository # @RELATION: DEPENDS_ON -> backend.src.services.clean_release.preparation_service # @INVARIANT: API never reports prepared status if preparation errors are present. from __future__ import annotations from datetime import datetime, timezone from typing import Any, Dict, List from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ...core.logger import belief_scope, logger from ...dependencies import get_clean_release_repository, get_config_manager from ...services.clean_release.preparation_service import prepare_candidate from ...services.clean_release.repository import CleanReleaseRepository from ...services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator from ...services.clean_release.report_builder import ComplianceReportBuilder from ...services.clean_release.compliance_execution_service import ComplianceExecutionService, ComplianceRunError from ...services.clean_release.dto import CandidateDTO, ManifestDTO, CandidateOverviewDTO, ComplianceRunDTO from ...services.clean_release.enums import ( ComplianceDecision, ComplianceStageName, ViolationCategory, ViolationSeverity, RunStatus, CandidateStatus, ) from ...models.clean_release import ( ComplianceRun, ComplianceStageRun, ComplianceViolation, CandidateArtifact, ReleaseCandidate, ) router = APIRouter(prefix="/api/clean-release", tags=["Clean Release"]) # [DEF:PrepareCandidateRequest:Class] # @PURPOSE: Request schema for candidate preparation endpoint. class PrepareCandidateRequest(BaseModel): candidate_id: str = Field(min_length=1) artifacts: List[Dict[str, Any]] = Field(default_factory=list) sources: List[str] = Field(default_factory=list) operator_id: str = Field(min_length=1) # [/DEF:PrepareCandidateRequest:Class] # [DEF:StartCheckRequest:Class] # @PURPOSE: Request schema for clean compliance check run startup. class StartCheckRequest(BaseModel): candidate_id: str = Field(min_length=1) profile: str = Field(default="enterprise-clean") execution_mode: str = Field(default="tui") triggered_by: str = Field(default="system") # [/DEF:StartCheckRequest:Class] # [DEF:RegisterCandidateRequest:Class] # @PURPOSE: Request schema for candidate registration endpoint. class RegisterCandidateRequest(BaseModel): id: str = Field(min_length=1) version: str = Field(min_length=1) source_snapshot_ref: str = Field(min_length=1) created_by: str = Field(min_length=1) # [/DEF:RegisterCandidateRequest:Class] # [DEF:ImportArtifactsRequest:Class] # @PURPOSE: Request schema for candidate artifact import endpoint. class ImportArtifactsRequest(BaseModel): artifacts: List[Dict[str, Any]] = Field(default_factory=list) # [/DEF:ImportArtifactsRequest:Class] # [DEF:BuildManifestRequest:Class] # @PURPOSE: Request schema for manifest build endpoint. class BuildManifestRequest(BaseModel): created_by: str = Field(default="system") # [/DEF:BuildManifestRequest:Class] # [DEF:CreateComplianceRunRequest:Class] # @PURPOSE: Request schema for compliance run creation with optional manifest pinning. class CreateComplianceRunRequest(BaseModel): requested_by: str = Field(min_length=1) manifest_id: str | None = None # [/DEF:CreateComplianceRunRequest:Class] # [DEF:register_candidate_v2_endpoint:Function] # @PURPOSE: Register a clean-release candidate for headless lifecycle. # @PRE: Candidate identifier is unique. # @POST: Candidate is persisted in DRAFT status. @router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED) async def register_candidate_v2_endpoint( payload: RegisterCandidateRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): existing = repository.get_candidate(payload.id) if existing is not None: raise HTTPException(status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"}) candidate = ReleaseCandidate( id=payload.id, version=payload.version, source_snapshot_ref=payload.source_snapshot_ref, created_by=payload.created_by, created_at=datetime.now(timezone.utc), status=CandidateStatus.DRAFT.value, ) repository.save_candidate(candidate) return CandidateDTO( id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, created_at=candidate.created_at, created_by=candidate.created_by, status=CandidateStatus(candidate.status), ) # [/DEF:register_candidate_v2_endpoint:Function] # [DEF:import_candidate_artifacts_v2_endpoint:Function] # @PURPOSE: Import candidate artifacts in headless flow. # @PRE: Candidate exists and artifacts array is non-empty. # @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT. @router.post("/candidates/{candidate_id}/artifacts") async def import_candidate_artifacts_v2_endpoint( candidate_id: str, payload: ImportArtifactsRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if candidate is None: raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"}) if not payload.artifacts: raise HTTPException(status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"}) for artifact in payload.artifacts: required = ("id", "path", "sha256", "size") for field_name in required: if field_name not in artifact: raise HTTPException( status_code=400, detail={"message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID"}, ) artifact_model = CandidateArtifact( id=str(artifact["id"]), candidate_id=candidate_id, path=str(artifact["path"]), sha256=str(artifact["sha256"]), size=int(artifact["size"]), detected_category=artifact.get("detected_category"), declared_category=artifact.get("declared_category"), source_uri=artifact.get("source_uri"), source_host=artifact.get("source_host"), metadata_json=artifact.get("metadata_json", {}), ) repository.save_artifact(artifact_model) if candidate.status == CandidateStatus.DRAFT.value: candidate.transition_to(CandidateStatus.PREPARED) repository.save_candidate(candidate) return {"status": "success"} # [/DEF:import_candidate_artifacts_v2_endpoint:Function] # [DEF:build_candidate_manifest_v2_endpoint:Function] # @PURPOSE: Build immutable manifest snapshot for prepared candidate. # @PRE: Candidate exists and has imported artifacts. # @POST: Returns created ManifestDTO with incremented version. @router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED) async def build_candidate_manifest_v2_endpoint( candidate_id: str, payload: BuildManifestRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): from ...services.clean_release.manifest_service import build_manifest_snapshot try: manifest = build_manifest_snapshot( repository=repository, candidate_id=candidate_id, created_by=payload.created_by, ) except ValueError as exc: raise HTTPException(status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"}) return ManifestDTO( id=manifest.id, candidate_id=manifest.candidate_id, manifest_version=manifest.manifest_version, manifest_digest=manifest.manifest_digest, artifacts_digest=manifest.artifacts_digest, created_at=manifest.created_at, created_by=manifest.created_by, source_snapshot_ref=manifest.source_snapshot_ref, content_json=manifest.content_json, ) # [/DEF:build_candidate_manifest_v2_endpoint:Function] # [DEF:get_candidate_overview_v2_endpoint:Function] # @PURPOSE: Return expanded candidate overview DTO for headless lifecycle visibility. # @PRE: Candidate exists. # @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints. @router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO) async def get_candidate_overview_v2_endpoint( candidate_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if candidate is None: raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"}) manifests = repository.get_manifests_by_candidate(candidate_id) latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None runs = [run for run in repository.check_runs.values() if run.candidate_id == candidate_id] latest_run = sorted(runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0] if runs else None latest_report = None if latest_run is not None: latest_report = next((r for r in repository.reports.values() if r.run_id == latest_run.id), None) latest_policy_snapshot = repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None latest_registry_snapshot = repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None approval_decisions = getattr(repository, "approval_decisions", []) latest_approval = ( sorted( [item for item in approval_decisions if item.candidate_id == candidate_id], key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, )[0] if approval_decisions and any(item.candidate_id == candidate_id for item in approval_decisions) else None ) publication_records = getattr(repository, "publication_records", []) latest_publication = ( sorted( [item for item in publication_records if item.candidate_id == candidate_id], key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, )[0] if publication_records and any(item.candidate_id == candidate_id for item in publication_records) else None ) return CandidateOverviewDTO( candidate_id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, status=CandidateStatus(candidate.status), latest_manifest_id=latest_manifest.id if latest_manifest else None, latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None, latest_run_id=latest_run.id if latest_run else None, latest_run_status=RunStatus(latest_run.status) if latest_run else None, latest_report_id=latest_report.id if latest_report else None, latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None, latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None, latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None, latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None, latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None, latest_approval_decision=latest_approval.decision if latest_approval else None, latest_publication_id=latest_publication.id if latest_publication else None, latest_publication_status=latest_publication.status if latest_publication else None, ) # [/DEF:get_candidate_overview_v2_endpoint:Function] # [DEF:prepare_candidate_endpoint:Function] # @PURPOSE: Prepare candidate with policy evaluation and deterministic manifest generation. # @PRE: Candidate and active policy exist in repository. # @POST: Returns preparation result including manifest reference and violations. @router.post("/candidates/prepare") async def prepare_candidate_endpoint( payload: PrepareCandidateRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: result = prepare_candidate( repository=repository, candidate_id=payload.candidate_id, artifacts=payload.artifacts, sources=payload.sources, operator_id=payload.operator_id, ) return result except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": str(exc), "code": "CLEAN_PREPARATION_ERROR"}, ) # [/DEF:prepare_candidate_endpoint:Function] # [DEF:start_check:Function] # @PURPOSE: Start and finalize a clean compliance check run and persist report artifacts. # @PRE: Active policy and candidate exist. # @POST: Returns accepted payload with check_run_id and started_at. @router.post("/checks", status_code=status.HTTP_202_ACCEPTED) async def start_check( payload: StartCheckRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): with belief_scope("clean_release.start_check"): logger.reason("Starting clean-release compliance check run") policy = repository.get_active_policy() if policy is None: raise HTTPException(status_code=409, detail={"message": "Active policy not found", "code": "POLICY_NOT_FOUND"}) candidate = repository.get_candidate(payload.candidate_id) if candidate is None: raise HTTPException(status_code=409, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"}) manifests = repository.get_manifests_by_candidate(payload.candidate_id) if not manifests: raise HTTPException(status_code=409, detail={"message": "No manifest found for candidate", "code": "MANIFEST_NOT_FOUND"}) latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] orchestrator = CleanComplianceOrchestrator(repository) run = orchestrator.start_check_run( candidate_id=payload.candidate_id, policy_id=policy.id, requested_by=payload.triggered_by, manifest_id=latest_manifest.id, ) forced = [ ComplianceStageRun( id=f"stage-{run.id}-1", run_id=run.id, stage_name=ComplianceStageName.DATA_PURITY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"} ), ComplianceStageRun( id=f"stage-{run.id}-2", run_id=run.id, stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"} ), ComplianceStageRun( id=f"stage-{run.id}-3", run_id=run.id, stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"} ), ComplianceStageRun( id=f"stage-{run.id}-4", run_id=run.id, stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"} ), ] run = orchestrator.execute_stages(run, forced_results=forced) run = orchestrator.finalize_run(run) if run.final_status == ComplianceDecision.BLOCKED.value: logger.explore("Run ended as BLOCKED, persisting synthetic external-source violation") violation = ComplianceViolation( id=f"viol-{run.id}", run_id=run.id, stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value, code="EXTERNAL_SOURCE_DETECTED", severity=ViolationSeverity.CRITICAL.value, message="Replace with approved internal server", evidence_json={"location": "external.example.com"} ) repository.save_violation(violation) builder = ComplianceReportBuilder(repository) report = builder.build_report_payload(run, repository.get_violations_by_run(run.id)) builder.persist_report(report) logger.reflect(f"Compliance report persisted for run_id={run.id}") return { "check_run_id": run.id, "candidate_id": run.candidate_id, "status": "running", "started_at": run.started_at.isoformat() if run.started_at else None, } # [/DEF:start_check:Function] # [DEF:get_check_status:Function] # @PURPOSE: Return terminal/intermediate status payload for a check run. # @PRE: check_run_id references an existing run. # @POST: Deterministic payload shape includes checks and violations arrays. @router.get("/checks/{check_run_id}") async def get_check_status(check_run_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)): with belief_scope("clean_release.get_check_status"): run = repository.get_check_run(check_run_id) if run is None: raise HTTPException(status_code=404, detail={"message": "Check run not found", "code": "CHECK_NOT_FOUND"}) logger.reflect(f"Returning check status for check_run_id={check_run_id}") return { "check_run_id": run.id, "candidate_id": run.candidate_id, "final_status": run.final_status, "started_at": run.started_at.isoformat() if run.started_at else None, "finished_at": run.finished_at.isoformat() if run.finished_at else None, "checks": [], # TODO: Map stages if needed "violations": [], # TODO: Map violations if needed } # [/DEF:get_check_status:Function] # [DEF:get_report:Function] # @PURPOSE: Return persisted compliance report by report_id. # @PRE: report_id references an existing report. # @POST: Returns serialized report object. @router.get("/reports/{report_id}") async def get_report(report_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository)): with belief_scope("clean_release.get_report"): report = repository.get_report(report_id) if report is None: raise HTTPException(status_code=404, detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"}) logger.reflect(f"Returning compliance report report_id={report_id}") return report.model_dump() # [/DEF:get_report:Function] # [/DEF:backend.src.api.routes.clean_release:Module]