# [DEF:PublicationService:Module] # @COMPLEXITY: 5 # @SEMANTICS: clean-release, publication, revoke, gate, lifecycle # @PURPOSE: Enforce publication and revocation gates with append-only publication records. # @LAYER: Domain # @RELATION: [DEPENDS_ON] ->[RepositoryRelations] # @RELATION: [DEPENDS_ON] ->[ApprovalService] # @RELATION: [DEPENDS_ON] ->[CleanReleaseModels] # @RELATION: [DEPENDS_ON] ->[AuditService] # @INVARIANT: Publication records are append-only snapshots; revoke mutates only publication status for targeted record. from __future__ import annotations from datetime import datetime, timezone from typing import List from uuid import uuid4 from ...core.logger import belief_scope, logger from ...models.clean_release import PublicationRecord from .audit_service import audit_preparation from .enums import ApprovalDecisionType, CandidateStatus, PublicationStatus from .exceptions import PublicationGateError from .repository import CleanReleaseRepository # [DEF:_get_or_init_publications_store:Function] # @PURPOSE: Provide in-memory append-only publication storage. # @PRE: repository is initialized. # @POST: Returns publication list attached to repository. def _get_or_init_publications_store( repository: CleanReleaseRepository, ) -> List[PublicationRecord]: publications = getattr(repository, "publication_records", None) if publications is None: publications = [] setattr(repository, "publication_records", publications) return publications # [/DEF:_get_or_init_publications_store:Function] # [DEF:_latest_publication_for_candidate:Function] # @PURPOSE: Resolve latest publication record for candidate. # @PRE: candidate_id is non-empty. # @POST: Returns latest record or None. def _latest_publication_for_candidate( repository: CleanReleaseRepository, candidate_id: str, ) -> PublicationRecord | None: records = [ item for item in _get_or_init_publications_store(repository) if item.candidate_id == candidate_id ] if not records: return None return sorted( records, key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, )[0] # [/DEF:_latest_publication_for_candidate:Function] # [DEF:_latest_approval_for_candidate:Function] # @PURPOSE: Resolve latest approval decision from repository decision store. # @PRE: candidate_id is non-empty. # @POST: Returns latest decision object or None. def _latest_approval_for_candidate( repository: CleanReleaseRepository, candidate_id: str ): decisions = getattr(repository, "approval_decisions", []) scoped = [item for item in decisions if item.candidate_id == candidate_id] if not scoped: return None return sorted( scoped, key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, )[0] # [/DEF:_latest_approval_for_candidate:Function] # [DEF:publish_candidate:Function] # @PURPOSE: Create immutable publication record for approved candidate. # @PRE: Candidate exists, report belongs to candidate, latest approval is APPROVED. # @POST: New ACTIVE publication record is appended. def publish_candidate( *, repository: CleanReleaseRepository, candidate_id: str, report_id: str, published_by: str, target_channel: str, publication_ref: str | None = None, ) -> PublicationRecord: with belief_scope("publication_service.publish_candidate"): logger.reason( f"[REASON] Evaluating publish gate candidate_id={candidate_id} report_id={report_id}" ) if not published_by or not published_by.strip(): raise PublicationGateError("published_by must be non-empty") if not target_channel or not target_channel.strip(): raise PublicationGateError("target_channel must be non-empty") candidate = repository.get_candidate(candidate_id) if candidate is None: raise PublicationGateError(f"candidate '{candidate_id}' not found") report = repository.get_report(report_id) if report is None: raise PublicationGateError(f"report '{report_id}' not found") if report.candidate_id != candidate_id: raise PublicationGateError("report belongs to another candidate") latest_approval = _latest_approval_for_candidate(repository, candidate_id) if ( latest_approval is None or latest_approval.decision != ApprovalDecisionType.APPROVED.value ): raise PublicationGateError("publish requires APPROVED decision") latest_publication = _latest_publication_for_candidate(repository, candidate_id) if ( latest_publication is not None and latest_publication.status == PublicationStatus.ACTIVE.value ): raise PublicationGateError("candidate already has active publication") if candidate.status == CandidateStatus.APPROVED.value: try: candidate.transition_to(CandidateStatus.PUBLISHED) repository.save_candidate(candidate) except Exception as exc: # noqa: BLE001 logger.explore( f"[EXPLORE] Candidate transition to PUBLISHED failed candidate_id={candidate_id}: {exc}" ) raise PublicationGateError(str(exc)) from exc record = PublicationRecord( id=f"pub-{uuid4()}", candidate_id=candidate_id, report_id=report_id, published_by=published_by, published_at=datetime.now(timezone.utc), target_channel=target_channel, publication_ref=publication_ref, status=PublicationStatus.ACTIVE.value, ) _get_or_init_publications_store(repository).append(record) audit_preparation( candidate_id, "PUBLISHED", repository=repository, actor=published_by ) logger.reflect( f"[REFLECT] Publication persisted candidate_id={candidate_id} publication_id={record.id}" ) return record # [/DEF:publish_candidate:Function] # [DEF:revoke_publication:Function] # @PURPOSE: Revoke existing publication record without deleting history. # @PRE: publication_id exists in repository publication store. # @POST: Target publication status becomes REVOKED and updated record is returned. def revoke_publication( *, repository: CleanReleaseRepository, publication_id: str, revoked_by: str, comment: str | None = None, ) -> PublicationRecord: with belief_scope("publication_service.revoke_publication"): logger.reason( f"[REASON] Evaluating revoke gate publication_id={publication_id}" ) if not revoked_by or not revoked_by.strip(): raise PublicationGateError("revoked_by must be non-empty") if not publication_id or not publication_id.strip(): raise PublicationGateError("publication_id must be non-empty") records = _get_or_init_publications_store(repository) record = next((item for item in records if item.id == publication_id), None) if record is None: raise PublicationGateError(f"publication '{publication_id}' not found") if record.status == PublicationStatus.REVOKED.value: raise PublicationGateError("publication is already revoked") record.status = PublicationStatus.REVOKED.value candidate = repository.get_candidate(record.candidate_id) if candidate is not None: # Lifecycle remains publication-driven; republish after revoke is supported by new publication record. repository.save_candidate(candidate) audit_preparation( record.candidate_id, f"REVOKED:{comment or ''}".strip(":"), repository=repository, actor=revoked_by, ) logger.reflect(f"[REFLECT] Publication revoked publication_id={publication_id}") return record # [/DEF:revoke_publication:Function] # [/DEF:backend.src.services.clean_release.publication_service:Module]