"""Fail-closed quarantine decisions for validated data assets. Persistence is intentionally delegated to the caller's transaction. This module derives stable decisions from immutable validation reports and blocks a quarantined or failed asset from training, production inference and derived processing until an explicit, separately persisted release action exists. """ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum from hashlib import sha256 from typing import Any import json from app.core.errors import AppError from app.services.data_contract_validation import QuarantineStatus, ValidationReport, ValidationStatus class AssetUse(StrEnum): TRAINING = "training" PRODUCTION_INFERENCE = "production_inference" DERIVED_PROCESSING = "derived_processing" EXPORT = "export" @dataclass(frozen=True) class QuarantineDecision: """A deterministic, auditable quarantine decision for one report.""" asset_id: str data_contract_key: str data_contract_version: str validation_report_sha256: str validation_status: ValidationStatus quarantine_status: QuarantineStatus reason_codes: tuple[str, ...] idempotency_key: str requires_explicit_release: bool = False @property def eligible_for_use(self) -> bool: return self.validation_status == ValidationStatus.PASSED and self.quarantine_status == QuarantineStatus.NOT_QUARANTINED def to_dict(self) -> dict[str, Any]: return { "asset_id": self.asset_id, "data_contract_key": self.data_contract_key, "data_contract_version": self.data_contract_version, "validation_report_sha256": self.validation_report_sha256, "validation_status": self.validation_status.value, "quarantine_status": self.quarantine_status.value, "reason_codes": list(self.reason_codes), "idempotency_key": self.idempotency_key, "requires_explicit_release": self.requires_explicit_release, "eligible_for_use": self.eligible_for_use, } class DataQuarantineService: """Derive and enforce safe use decisions from validation results.""" @staticmethod def decide( report: ValidationReport, *, previous: QuarantineDecision | None = None, ) -> QuarantineDecision: """Create a stable decision without silently releasing old quarantines. A fresh passing validation report can be persisted as a new validated version by the import transaction. It cannot automatically release an existing quarantined record: the caller must explicitly record that reviewed state transition against the new report/version. """ is_quarantined = report.validation_status == ValidationStatus.FAILED or report.quarantine_status == QuarantineStatus.QUARANTINED failure_codes = tuple( sorted( { issue.code for issue in report.issues if issue.severity.value == "error" or is_quarantined } ) ) requires_explicit_release = False reason_codes = failure_codes status = QuarantineStatus.QUARANTINED if is_quarantined else QuarantineStatus.NOT_QUARANTINED if previous is not None and previous.quarantine_status == QuarantineStatus.QUARANTINED and not is_quarantined: status = QuarantineStatus.QUARANTINED requires_explicit_release = True reason_codes = ("QUARANTINE_RELEASE_REQUIRES_EXPLICIT_PERSISTENCE",) idempotency_key = _decision_key( asset_id=report.asset_id, contract_key=report.data_contract_key, contract_version=report.data_contract_version, report_sha256=report.report_sha256, quarantine_status=status, reason_codes=reason_codes, requires_explicit_release=requires_explicit_release, ) return QuarantineDecision( asset_id=report.asset_id, data_contract_key=report.data_contract_key, data_contract_version=report.data_contract_version, validation_report_sha256=report.report_sha256, validation_status=report.validation_status, quarantine_status=status, reason_codes=reason_codes, idempotency_key=idempotency_key, requires_explicit_release=requires_explicit_release, ) @staticmethod def require_eligible(decision: QuarantineDecision, *, use: AssetUse) -> None: """Raise a typed error before a non-eligible artifact reaches a pipeline.""" if decision.eligible_for_use: return raise AppError( code="DATASET_QUARANTINED", message="Dataset is quarantined or failed validation and cannot enter this pipeline.", status_code=409, details={ "asset_id": decision.asset_id, "use": use.value, "quarantine_status": decision.quarantine_status.value, "validation_status": decision.validation_status.value, "reason_codes": list(decision.reason_codes), "validation_report_sha256": decision.validation_report_sha256, "idempotency_key": decision.idempotency_key, "requires_explicit_release": decision.requires_explicit_release, }, ) def _decision_key( *, asset_id: str, contract_key: str, contract_version: str, report_sha256: str, quarantine_status: QuarantineStatus, reason_codes: tuple[str, ...], requires_explicit_release: bool, ) -> str: payload = { "asset_id": asset_id, "contract_key": contract_key, "contract_version": contract_version, "report_sha256": report_sha256, "quarantine_status": quarantine_status.value, "reason_codes": list(reason_codes), "requires_explicit_release": requires_explicit_release, } return sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")).hexdigest()