feat(provenance): govern source snapshots and data inputs
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user