GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
161 lines
6.1 KiB
Python
161 lines
6.1 KiB
Python
"""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()
|