feat(clean-release): complete compliance redesign phases and polish tasks T047-T052
This commit is contained in:
173
backend/src/services/clean_release/publication_service.py
Normal file
173
backend/src/services/clean_release/publication_service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# [DEF:backend.src.services.clean_release.publication_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, publication, revoke, gate, lifecycle
|
||||
# @PURPOSE: Enforce publication and revocation gates with append-only publication records.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.approval_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.audit_service
|
||||
# @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]
|
||||
Reference in New Issue
Block a user