- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
212 lines
7.5 KiB
Python
212 lines
7.5 KiB
Python
# #region ApprovalService [C:5] [TYPE Module] [SEMANTICS clean-release, approval, decision, lifecycle, gate]
|
|
# @BRIEF Enforce approval/rejection gates over immutable compliance reports.
|
|
# @LAYER: Domain
|
|
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
|
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
|
# @RELATION DEPENDS_ON -> [AuditService]
|
|
# @INVARIANT: Approval is allowed only for PASSED report bound to candidate; decisions are append-only.
|
|
|
|
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 ApprovalDecision
|
|
from .audit_service import audit_preparation
|
|
from .enums import ApprovalDecisionType, CandidateStatus, ComplianceDecision
|
|
from .exceptions import ApprovalGateError
|
|
from .repository import CleanReleaseRepository
|
|
|
|
|
|
# #region _get_or_init_decisions_store [TYPE Function]
|
|
# @BRIEF Provide append-only in-memory storage for approval decisions.
|
|
# @PRE: repository is initialized.
|
|
# @POST: Returns mutable decision list attached to repository.
|
|
def _get_or_init_decisions_store(
|
|
repository: CleanReleaseRepository,
|
|
) -> List[ApprovalDecision]:
|
|
decisions = getattr(repository, "approval_decisions", None)
|
|
if decisions is None:
|
|
decisions = []
|
|
setattr(repository, "approval_decisions", decisions)
|
|
return decisions
|
|
|
|
|
|
# #endregion _get_or_init_decisions_store
|
|
|
|
|
|
# #region _latest_decision_for_candidate [TYPE Function]
|
|
# @BRIEF Resolve latest approval decision for candidate from append-only store.
|
|
# @PRE: candidate_id is non-empty.
|
|
# @POST: Returns latest ApprovalDecision or None.
|
|
def _latest_decision_for_candidate(
|
|
repository: CleanReleaseRepository, candidate_id: str
|
|
) -> ApprovalDecision | None:
|
|
decisions = _get_or_init_decisions_store(repository)
|
|
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]
|
|
|
|
|
|
# #endregion _latest_decision_for_candidate
|
|
|
|
|
|
# #region _resolve_candidate_and_report [TYPE Function]
|
|
# @BRIEF Validate candidate/report existence and ownership prior to decision persistence.
|
|
# @PRE: candidate_id and report_id are non-empty.
|
|
# @POST: Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
|
|
def _resolve_candidate_and_report(
|
|
repository: CleanReleaseRepository,
|
|
*,
|
|
candidate_id: str,
|
|
report_id: str,
|
|
):
|
|
candidate = repository.get_candidate(candidate_id)
|
|
if candidate is None:
|
|
raise ApprovalGateError(f"candidate '{candidate_id}' not found")
|
|
|
|
report = repository.get_report(report_id)
|
|
if report is None:
|
|
raise ApprovalGateError(f"report '{report_id}' not found")
|
|
|
|
if report.candidate_id != candidate_id:
|
|
raise ApprovalGateError("report belongs to another candidate")
|
|
|
|
return candidate, report
|
|
|
|
|
|
# #endregion _resolve_candidate_and_report
|
|
|
|
|
|
# #region approve_candidate [TYPE Function]
|
|
# @BRIEF Persist immutable APPROVED decision and advance candidate lifecycle to APPROVED.
|
|
# @PRE: Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
|
|
# @POST: Approval decision is appended and candidate transitions to APPROVED.
|
|
def approve_candidate(
|
|
*,
|
|
repository: CleanReleaseRepository,
|
|
candidate_id: str,
|
|
report_id: str,
|
|
decided_by: str,
|
|
comment: str | None = None,
|
|
) -> ApprovalDecision:
|
|
with belief_scope("approval_service.approve_candidate"):
|
|
logger.reason(
|
|
f"[REASON] Evaluating approve gate candidate_id={candidate_id} report_id={report_id}"
|
|
)
|
|
|
|
if not decided_by or not decided_by.strip():
|
|
raise ApprovalGateError("decided_by must be non-empty")
|
|
|
|
candidate, report = _resolve_candidate_and_report(
|
|
repository,
|
|
candidate_id=candidate_id,
|
|
report_id=report_id,
|
|
)
|
|
|
|
if report.final_status != ComplianceDecision.PASSED.value:
|
|
raise ApprovalGateError("approve requires PASSED compliance report")
|
|
|
|
latest = _latest_decision_for_candidate(repository, candidate_id)
|
|
if (
|
|
latest is not None
|
|
and latest.decision == ApprovalDecisionType.APPROVED.value
|
|
):
|
|
raise ApprovalGateError("candidate is already approved")
|
|
|
|
if candidate.status == CandidateStatus.APPROVED.value:
|
|
raise ApprovalGateError("candidate is already approved")
|
|
|
|
try:
|
|
if candidate.status != CandidateStatus.CHECK_PASSED.value:
|
|
raise ApprovalGateError(
|
|
f"candidate status '{candidate.status}' cannot transition to APPROVED"
|
|
)
|
|
candidate.transition_to(CandidateStatus.APPROVED)
|
|
repository.save_candidate(candidate)
|
|
except ApprovalGateError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.explore(
|
|
f"[EXPLORE] Candidate transition to APPROVED failed candidate_id={candidate_id}: {exc}"
|
|
)
|
|
raise ApprovalGateError(str(exc)) from exc
|
|
|
|
decision = ApprovalDecision(
|
|
id=f"approve-{uuid4()}",
|
|
candidate_id=candidate_id,
|
|
report_id=report_id,
|
|
decision=ApprovalDecisionType.APPROVED.value,
|
|
decided_by=decided_by,
|
|
decided_at=datetime.now(timezone.utc),
|
|
comment=comment,
|
|
)
|
|
_get_or_init_decisions_store(repository).append(decision)
|
|
audit_preparation(
|
|
candidate_id, "APPROVED", repository=repository, actor=decided_by
|
|
)
|
|
logger.reflect(
|
|
f"[REFLECT] Approval persisted candidate_id={candidate_id} decision_id={decision.id}"
|
|
)
|
|
return decision
|
|
|
|
|
|
# #endregion approve_candidate
|
|
|
|
|
|
# #region reject_candidate [TYPE Function]
|
|
# @BRIEF Persist immutable REJECTED decision without promoting candidate lifecycle.
|
|
# @PRE: Candidate exists and report belongs to candidate.
|
|
# @POST: Rejected decision is appended; candidate lifecycle is unchanged.
|
|
def reject_candidate(
|
|
*,
|
|
repository: CleanReleaseRepository,
|
|
candidate_id: str,
|
|
report_id: str,
|
|
decided_by: str,
|
|
comment: str | None = None,
|
|
) -> ApprovalDecision:
|
|
with belief_scope("approval_service.reject_candidate"):
|
|
logger.reason(
|
|
f"[REASON] Evaluating reject decision candidate_id={candidate_id} report_id={report_id}"
|
|
)
|
|
|
|
if not decided_by or not decided_by.strip():
|
|
raise ApprovalGateError("decided_by must be non-empty")
|
|
|
|
_resolve_candidate_and_report(
|
|
repository,
|
|
candidate_id=candidate_id,
|
|
report_id=report_id,
|
|
)
|
|
|
|
decision = ApprovalDecision(
|
|
id=f"reject-{uuid4()}",
|
|
candidate_id=candidate_id,
|
|
report_id=report_id,
|
|
decision=ApprovalDecisionType.REJECTED.value,
|
|
decided_by=decided_by,
|
|
decided_at=datetime.now(timezone.utc),
|
|
comment=comment,
|
|
)
|
|
_get_or_init_decisions_store(repository).append(decision)
|
|
audit_preparation(
|
|
candidate_id, "REJECTED", repository=repository, actor=decided_by
|
|
)
|
|
logger.reflect(
|
|
f"[REFLECT] Rejection persisted candidate_id={candidate_id} decision_id={decision.id}"
|
|
)
|
|
return decision
|
|
|
|
|
|
# #endregion reject_candidate
|
|
|
|
# #endregion backend.src.services.clean_release.approval_service
|