# [DEF:backend.src.services.clean_release.candidate_service:Module] # @TIER: CRITICAL # @SEMANTICS: clean-release, candidate, artifacts, lifecycle, validation # @PURPOSE: Register release candidates with validated artifacts and advance lifecycle through legal transitions. # @LAYER: Domain # @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository # @RELATION: DEPENDS_ON -> backend.src.models.clean_release # @PRE: candidate_id must be unique; artifacts input must be non-empty and valid. # @POST: candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only. # @INVARIANT: Candidate lifecycle transitions are delegated to domain guard logic. from __future__ import annotations from datetime import datetime, timezone from typing import Any, Dict, Iterable, List from ...models.clean_release import CandidateArtifact, ReleaseCandidate from .enums import CandidateStatus from .repository import CleanReleaseRepository # [DEF:_validate_artifacts:Function] # @PURPOSE: Validate raw artifact payload list for required fields and shape. # @PRE: artifacts payload is provided by caller. # @POST: Returns normalized artifact list or raises ValueError. def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: normalized = list(artifacts) if not normalized: raise ValueError("artifacts must not be empty") required_fields = ("id", "path", "sha256", "size") for index, artifact in enumerate(normalized): if not isinstance(artifact, dict): raise ValueError(f"artifact[{index}] must be an object") for field in required_fields: if field not in artifact: raise ValueError(f"artifact[{index}] missing required field '{field}'") if not str(artifact["id"]).strip(): raise ValueError(f"artifact[{index}] field 'id' must be non-empty") if not str(artifact["path"]).strip(): raise ValueError(f"artifact[{index}] field 'path' must be non-empty") if not str(artifact["sha256"]).strip(): raise ValueError(f"artifact[{index}] field 'sha256' must be non-empty") if not isinstance(artifact["size"], int) or artifact["size"] <= 0: raise ValueError(f"artifact[{index}] field 'size' must be a positive integer") return normalized # [/DEF:_validate_artifacts:Function] # [DEF:register_candidate:Function] # @PURPOSE: Register a candidate and persist its artifacts with legal lifecycle transition. # @PRE: candidate_id must be unique and artifacts must pass validation. # @POST: Candidate exists in repository with PREPARED status and artifacts persisted. def register_candidate( repository: CleanReleaseRepository, candidate_id: str, version: str, source_snapshot_ref: str, created_by: str, artifacts: Iterable[Dict[str, Any]], ) -> ReleaseCandidate: if not candidate_id or not candidate_id.strip(): raise ValueError("candidate_id must be non-empty") if not version or not version.strip(): raise ValueError("version must be non-empty") if not source_snapshot_ref or not source_snapshot_ref.strip(): raise ValueError("source_snapshot_ref must be non-empty") if not created_by or not created_by.strip(): raise ValueError("created_by must be non-empty") existing = repository.get_candidate(candidate_id) if existing is not None: raise ValueError(f"candidate '{candidate_id}' already exists") validated_artifacts = _validate_artifacts(artifacts) candidate = ReleaseCandidate( id=candidate_id, version=version, source_snapshot_ref=source_snapshot_ref, created_by=created_by, created_at=datetime.now(timezone.utc), status=CandidateStatus.DRAFT.value, ) repository.save_candidate(candidate) for artifact_payload in validated_artifacts: artifact = CandidateArtifact( id=str(artifact_payload["id"]), candidate_id=candidate_id, path=str(artifact_payload["path"]), sha256=str(artifact_payload["sha256"]), size=int(artifact_payload["size"]), detected_category=artifact_payload.get("detected_category"), declared_category=artifact_payload.get("declared_category"), source_uri=artifact_payload.get("source_uri"), source_host=artifact_payload.get("source_host"), metadata_json=artifact_payload.get("metadata_json", {}), ) repository.save_artifact(artifact) candidate.transition_to(CandidateStatus.PREPARED) repository.save_candidate(candidate) return candidate # [/DEF:register_candidate:Function] # [/DEF:backend.src.services.clean_release.candidate_service:Module]