Files
geointel/backend/app/services/data_quarantine_service.py
T
Jens faeb58ef6d
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
Initial public release
2026-08-31 21:56:53 +02:00

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()