|
|
@@ -0,0 +1,5923 @@
|
|
|
+"""PostgreSQL source-of-truth for immutable data-rule assets."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import copy
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+import re
|
|
|
+import secrets
|
|
|
+from collections.abc import Mapping
|
|
|
+from datetime import UTC, datetime
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from sqlalchemy import text
|
|
|
+from sqlalchemy.exc import IntegrityError
|
|
|
+
|
|
|
+from app.core.common.identifiers import (
|
|
|
+ ensure_governance_uid,
|
|
|
+ new_governance_uid,
|
|
|
+)
|
|
|
+from app.core.data_rules.contracts import (
|
|
|
+ dataflow_spec_hash,
|
|
|
+ rule_spec_hash,
|
|
|
+ standard_spec_hash,
|
|
|
+ validate_dataflow_spec,
|
|
|
+ validate_rule_candidate,
|
|
|
+ validate_rule_spec,
|
|
|
+ validate_standard_spec,
|
|
|
+)
|
|
|
+from app.core.data_rules.execution_contracts import (
|
|
|
+ validate_dataset_binding,
|
|
|
+ validate_schema_snapshot,
|
|
|
+)
|
|
|
+
|
|
|
+RULE_CATEGORIES = {
|
|
|
+ "general",
|
|
|
+ "reusable",
|
|
|
+ "flow_scoped",
|
|
|
+ "standard_clause",
|
|
|
+}
|
|
|
+GENERATED_KINDS = {"rulespec", "cel", "sql", "polars", "generated_python"}
|
|
|
+COMPONENT_KINDS = {"rule.apply", "quality.check"}
|
|
|
+PLAN_BACKENDS = {
|
|
|
+ "sql_pushdown",
|
|
|
+ "polars_batch",
|
|
|
+ "quality_check",
|
|
|
+ "generated_python",
|
|
|
+ "external_adapter",
|
|
|
+}
|
|
|
+TERMINAL_DATAFLOW_CREATE_ERRORS = {
|
|
|
+ "create_intent_integrity_failed",
|
|
|
+ "create_intent_validation_failed",
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _uid(value: Any, label: str) -> str:
|
|
|
+ try:
|
|
|
+ return ensure_governance_uid({"uid": str(value)})
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError(f"{label} must be a valid UUIDv7") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _text(value: Any, label: str, maximum: int) -> str:
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
+ raise ValueError(f"{label} is required")
|
|
|
+ normalized = value.strip()
|
|
|
+ if len(normalized) > maximum:
|
|
|
+ raise ValueError(f"{label} exceeds {maximum} characters")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _json(value: Any) -> str:
|
|
|
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
|
+
|
|
|
+
|
|
|
+def _object(value: Any, label: str) -> dict[str, Any]:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ return value
|
|
|
+ if isinstance(value, str):
|
|
|
+ try:
|
|
|
+ decoded = json.loads(value)
|
|
|
+ except json.JSONDecodeError as exc:
|
|
|
+ raise ValueError(f"{label} contains invalid JSON") from exc
|
|
|
+ if isinstance(decoded, dict):
|
|
|
+ return decoded
|
|
|
+ raise ValueError(f"{label} must be an object")
|
|
|
+
|
|
|
+
|
|
|
+def _array(value: Any, label: str) -> list[Any]:
|
|
|
+ if isinstance(value, list):
|
|
|
+ return value
|
|
|
+ if isinstance(value, str):
|
|
|
+ try:
|
|
|
+ decoded = json.loads(value)
|
|
|
+ except json.JSONDecodeError as exc:
|
|
|
+ raise ValueError(f"{label} contains invalid JSON") from exc
|
|
|
+ if isinstance(decoded, list):
|
|
|
+ return decoded
|
|
|
+ raise ValueError(f"{label} must be an array")
|
|
|
+
|
|
|
+
|
|
|
+def _digest(value: Any, label: str) -> str:
|
|
|
+ normalized = str(value or "")
|
|
|
+ if not re.fullmatch(r"[0-9a-f]{64}", normalized):
|
|
|
+ raise ValueError(f"{label} must be a sha256 hex digest")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _canonical_hash(value: Any) -> str:
|
|
|
+ canonical = json.dumps(
|
|
|
+ value,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ensure_ascii=False,
|
|
|
+ )
|
|
|
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _bounded_object(
|
|
|
+ value: Any, label: str, *, maximum_bytes: int = 131_072
|
|
|
+) -> dict[str, Any]:
|
|
|
+ normalized = copy.deepcopy(_object(value, label))
|
|
|
+ if len(_json(normalized).encode("utf-8")) > maximum_bytes:
|
|
|
+ raise ValueError(f"{label} is too large")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+class DataRuleRepository:
|
|
|
+ """Persist versions and enforce all lifecycle transitions server-side."""
|
|
|
+
|
|
|
+ def __init__(self, session):
|
|
|
+ self.session = session
|
|
|
+
|
|
|
+ def reserve_dataflow_draft(
|
|
|
+ self, *, actor_uid: str, lifetime_seconds: int = 600
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ if (
|
|
|
+ isinstance(lifetime_seconds, bool)
|
|
|
+ or not isinstance(lifetime_seconds, int)
|
|
|
+ or lifetime_seconds < 60
|
|
|
+ or lifetime_seconds > 1800
|
|
|
+ ):
|
|
|
+ raise ValueError("draft reservation lifetime is invalid")
|
|
|
+ reservation_id = new_governance_uid()
|
|
|
+ dataflow_uid = new_governance_uid()
|
|
|
+ nonce = secrets.token_urlsafe(32)
|
|
|
+ nonce_hash = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_draft_reservations "
|
|
|
+ "(id, dataflow_uid, actor_uid, nonce_hash, expires_at) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
|
|
|
+ "CAST(:actor_uid AS uuid), :nonce_hash, "
|
|
|
+ "CURRENT_TIMESTAMP + (:lifetime_seconds * INTERVAL '1 second')) "
|
|
|
+ "RETURNING expires_at /* reserve_dataflow_draft */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "actor_uid": actor,
|
|
|
+ "nonce_hash": nonce_hash,
|
|
|
+ "lifetime_seconds": lifetime_seconds,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise RuntimeError("dataflow draft reservation was not persisted")
|
|
|
+ expires_at = row["expires_at"]
|
|
|
+ return {
|
|
|
+ "reservation_id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "nonce": nonce,
|
|
|
+ "expires_at": (
|
|
|
+ expires_at.isoformat()
|
|
|
+ if hasattr(expires_at, "isoformat")
|
|
|
+ else str(expires_at)
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _draft_receipt(receipt: dict[str, Any], *, actor_uid: str) -> dict[str, str]:
|
|
|
+ if not isinstance(receipt, dict) or set(receipt) != {
|
|
|
+ "reservation_id",
|
|
|
+ "dataflow_uid",
|
|
|
+ "nonce",
|
|
|
+ }:
|
|
|
+ raise ValueError("dataflow draft receipt is invalid")
|
|
|
+ reservation_id = _uid(receipt.get("reservation_id"), "reservation_id")
|
|
|
+ dataflow_uid = _uid(receipt.get("dataflow_uid"), "dataflow_uid")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ nonce = _text(receipt.get("nonce"), "draft nonce", 200)
|
|
|
+ nonce_hash = hashlib.sha256(nonce.encode("utf-8")).hexdigest()
|
|
|
+ return {
|
|
|
+ "id": reservation_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "actor_uid": actor,
|
|
|
+ "nonce_hash": nonce_hash,
|
|
|
+ }
|
|
|
+
|
|
|
+ def begin_dataflow_create(
|
|
|
+ self,
|
|
|
+ receipt: dict[str, Any],
|
|
|
+ *,
|
|
|
+ actor_uid: str,
|
|
|
+ create_request: dict[str, Any],
|
|
|
+ create_intent: dict[str, Any],
|
|
|
+ lease_seconds: int = 60,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ """Atomically claim a create attempt or replay its committed result."""
|
|
|
+ if (
|
|
|
+ isinstance(lease_seconds, bool)
|
|
|
+ or not isinstance(lease_seconds, int)
|
|
|
+ or lease_seconds < 10
|
|
|
+ or lease_seconds > 300
|
|
|
+ ):
|
|
|
+ raise ValueError("dataflow create lease is invalid")
|
|
|
+ values = self._draft_receipt(receipt, actor_uid=actor_uid)
|
|
|
+ request_value = _bounded_object(create_request, "dataflow create request")
|
|
|
+ intent_value = _bounded_object(create_intent, "dataflow create intent")
|
|
|
+ if request_value.get("intent") != intent_value:
|
|
|
+ raise ValueError("dataflow create request intent is invalid")
|
|
|
+ if intent_value.get("dataflow_uid") != values["dataflow_uid"]:
|
|
|
+ raise ValueError("dataflow create intent UID does not match receipt")
|
|
|
+ request_digest = _canonical_hash(request_value)
|
|
|
+ lease_token = new_governance_uid()
|
|
|
+ claimed = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_draft_reservations "
|
|
|
+ "SET state = 'creating', lease_token = CAST(:lease_token AS uuid), "
|
|
|
+ "lease_expires_at = CURRENT_TIMESTAMP + "
|
|
|
+ "(:lease_seconds * INTERVAL '1 second'), "
|
|
|
+ "attempt_count = attempt_count + 1, "
|
|
|
+ "create_request = COALESCE("
|
|
|
+ "create_request, CAST(:create_request AS jsonb)), "
|
|
|
+ "create_intent = COALESCE("
|
|
|
+ "create_intent, CAST(:create_intent AS jsonb)), "
|
|
|
+ "request_digest = COALESCE(request_digest, :request_digest), "
|
|
|
+ "create_started_at = COALESCE("
|
|
|
+ "create_started_at, CURRENT_TIMESTAMP), "
|
|
|
+ "error_code = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND actor_uid = CAST(:actor_uid AS uuid) "
|
|
|
+ "AND nonce_hash = :nonce_hash "
|
|
|
+ "AND (request_digest IS NULL "
|
|
|
+ "OR request_digest = :request_digest) "
|
|
|
+ "AND consumed_at IS NULL "
|
|
|
+ "AND (expires_at > CURRENT_TIMESTAMP OR attempt_count > 0) "
|
|
|
+ "AND (state IN ('reserved', 'failed') OR "
|
|
|
+ "(state = 'creating' AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
+ "RETURNING dataflow_uid::text, attempt_count, "
|
|
|
+ "create_intent, request_digest "
|
|
|
+ "/* begin_dataflow_create */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ **values,
|
|
|
+ "lease_token": lease_token,
|
|
|
+ "lease_seconds": lease_seconds,
|
|
|
+ "create_request": _json(request_value),
|
|
|
+ "create_intent": _json(intent_value),
|
|
|
+ "request_digest": request_digest,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if claimed is not None:
|
|
|
+ return {
|
|
|
+ "status": "claimed",
|
|
|
+ "dataflow_uid": str(claimed["dataflow_uid"]),
|
|
|
+ "lease_token": lease_token,
|
|
|
+ "attempt": int(claimed["attempt_count"]),
|
|
|
+ "create_intent": _object(
|
|
|
+ claimed["create_intent"], "dataflow create intent"
|
|
|
+ ),
|
|
|
+ "request_digest": str(claimed["request_digest"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT state, dataflow_uid::text, result, "
|
|
|
+ "result_digest, lease_expires_at, expires_at, "
|
|
|
+ "request_digest, create_intent "
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND actor_uid = CAST(:actor_uid AS uuid) "
|
|
|
+ "AND nonce_hash = :nonce_hash "
|
|
|
+ "/* inspect_dataflow_create */"
|
|
|
+ ),
|
|
|
+ values,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is not None and row["request_digest"] not in {
|
|
|
+ None,
|
|
|
+ request_digest,
|
|
|
+ }:
|
|
|
+ raise ValueError("dataflow_create_request_conflict")
|
|
|
+ if row is not None and row["state"] == "completed":
|
|
|
+ if str(row["request_digest"]) != request_digest:
|
|
|
+ raise ValueError("dataflow_create_request_conflict")
|
|
|
+ result = _object(row["result"], "dataflow create result")
|
|
|
+ if _canonical_hash(result) != str(row["result_digest"]):
|
|
|
+ raise RuntimeError("dataflow create result integrity check failed")
|
|
|
+ return {
|
|
|
+ "status": "completed",
|
|
|
+ "dataflow_uid": str(row["dataflow_uid"]),
|
|
|
+ "result": copy.deepcopy(result),
|
|
|
+ "request_digest": request_digest,
|
|
|
+ }
|
|
|
+ if row is not None and row["state"] == "creating":
|
|
|
+ raise ValueError("dataflow_create_in_progress")
|
|
|
+ raise ValueError("draft_reservation_refresh_required")
|
|
|
+
|
|
|
+ def complete_dataflow_create(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ reservation_id: str,
|
|
|
+ dataflow_uid: str,
|
|
|
+ lease_token: str,
|
|
|
+ request_digest: str,
|
|
|
+ dataflow_node_id: int,
|
|
|
+ result: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ reservation = _uid(reservation_id, "reservation_id")
|
|
|
+ flow_uid = _uid(dataflow_uid, "dataflow_uid")
|
|
|
+ lease = _uid(lease_token, "lease_token")
|
|
|
+ request_hash = _digest(request_digest, "request_digest")
|
|
|
+ if (
|
|
|
+ isinstance(dataflow_node_id, bool)
|
|
|
+ or not isinstance(dataflow_node_id, int)
|
|
|
+ or dataflow_node_id < 0
|
|
|
+ ):
|
|
|
+ raise ValueError("dataflow_node_id is invalid")
|
|
|
+ normalized_result = _object(result, "dataflow create result")
|
|
|
+ result_digest = _canonical_hash(normalized_result)
|
|
|
+ completed = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_draft_reservations "
|
|
|
+ "SET state = 'completed', result = CAST(:result AS jsonb), "
|
|
|
+ "result_digest = :result_digest, "
|
|
|
+ "dataflow_node_id = :dataflow_node_id, "
|
|
|
+ "completed_at = CURRENT_TIMESTAMP, "
|
|
|
+ "consumed_at = CURRENT_TIMESTAMP, lease_token = NULL, "
|
|
|
+ "lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND state = 'creating' "
|
|
|
+ "AND lease_token = CAST(:lease_token AS uuid) "
|
|
|
+ "AND request_digest = :request_digest "
|
|
|
+ "RETURNING result /* complete_dataflow_create */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": reservation,
|
|
|
+ "dataflow_uid": flow_uid,
|
|
|
+ "lease_token": lease,
|
|
|
+ "request_digest": request_hash,
|
|
|
+ "result": _json(normalized_result),
|
|
|
+ "result_digest": result_digest,
|
|
|
+ "dataflow_node_id": dataflow_node_id,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if completed is None:
|
|
|
+ replay = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT result, result_digest, request_digest "
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND state = 'completed' "
|
|
|
+ "AND request_digest = :request_digest "
|
|
|
+ "/* replay_completed_dataflow_create */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": reservation,
|
|
|
+ "dataflow_uid": flow_uid,
|
|
|
+ "request_digest": request_hash,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if replay is None:
|
|
|
+ raise RuntimeError("dataflow create lease was lost")
|
|
|
+ replay_result = _object(replay["result"], "dataflow create result")
|
|
|
+ if _canonical_hash(replay_result) != str(replay["result_digest"]):
|
|
|
+ raise RuntimeError("dataflow create result integrity check failed")
|
|
|
+ return copy.deepcopy(replay_result)
|
|
|
+ return copy.deepcopy(normalized_result)
|
|
|
+
|
|
|
+ def fail_dataflow_create(
|
|
|
+ self, *, reservation_id: str, lease_token: str, error_code: str
|
|
|
+ ) -> None:
|
|
|
+ failed = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_draft_reservations "
|
|
|
+ "SET state = 'failed', error_code = :error_code, "
|
|
|
+ "failed_at = CURRENT_TIMESTAMP, lease_token = NULL, "
|
|
|
+ "lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND state = 'creating' "
|
|
|
+ "AND lease_token = CAST(:lease_token AS uuid) "
|
|
|
+ "RETURNING id /* fail_dataflow_create */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": _uid(reservation_id, "reservation_id"),
|
|
|
+ "lease_token": _uid(lease_token, "lease_token"),
|
|
|
+ "error_code": _text(error_code, "error_code", 100),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if failed is None:
|
|
|
+ raise RuntimeError("dataflow create lease was lost")
|
|
|
+
|
|
|
+ def commit_dataflow_create_claim(self) -> None:
|
|
|
+ """Durably publish the lease before the Neo4j side effect starts."""
|
|
|
+ self.session.commit()
|
|
|
+
|
|
|
+ def renew_dataflow_create_lease(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ reservation_id: str,
|
|
|
+ lease_token: str,
|
|
|
+ lease_seconds: int = 60,
|
|
|
+ ) -> None:
|
|
|
+ """Fence a side effect with a fresh lease owned by this worker."""
|
|
|
+ if (
|
|
|
+ isinstance(lease_seconds, bool)
|
|
|
+ or not isinstance(lease_seconds, int)
|
|
|
+ or lease_seconds < 10
|
|
|
+ or lease_seconds > 300
|
|
|
+ ):
|
|
|
+ raise ValueError("dataflow create lease is invalid")
|
|
|
+ renewed = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_draft_reservations "
|
|
|
+ "SET lease_expires_at = CURRENT_TIMESTAMP + "
|
|
|
+ "(:lease_seconds * INTERVAL '1 second'), "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND state = 'creating' "
|
|
|
+ "AND lease_token = CAST(:lease_token AS uuid) "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP "
|
|
|
+ "RETURNING id /* renew_dataflow_create_lease */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": _uid(reservation_id, "reservation_id"),
|
|
|
+ "lease_token": _uid(lease_token, "lease_token"),
|
|
|
+ "lease_seconds": lease_seconds,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if renewed is None:
|
|
|
+ raise RuntimeError("dataflow create lease was lost")
|
|
|
+
|
|
|
+ def commit_dataflow_create_failure(
|
|
|
+ self, *, reservation_id: str, lease_token: str, error_code: str
|
|
|
+ ) -> None:
|
|
|
+ self.fail_dataflow_create(
|
|
|
+ reservation_id=reservation_id,
|
|
|
+ lease_token=lease_token,
|
|
|
+ error_code=error_code,
|
|
|
+ )
|
|
|
+ self.session.commit()
|
|
|
+
|
|
|
+ def list_reconcilable_dataflow_creates(
|
|
|
+ self, *, limit: int = 100
|
|
|
+ ) -> list[dict[str, Any]]:
|
|
|
+ if (
|
|
|
+ isinstance(limit, bool)
|
|
|
+ or not isinstance(limit, int)
|
|
|
+ or limit < 1
|
|
|
+ or limit > 500
|
|
|
+ ):
|
|
|
+ raise ValueError("reconciliation limit is invalid")
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS reservation_id, "
|
|
|
+ "dataflow_uid::text, state, attempt_count, "
|
|
|
+ "request_digest, error_code, lease_expires_at, updated_at "
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
+ "WHERE request_digest IS NOT NULL "
|
|
|
+ "AND create_request IS NOT NULL "
|
|
|
+ "AND create_intent IS NOT NULL "
|
|
|
+ "AND (error_code IS NULL OR error_code NOT IN "
|
|
|
+ "('create_intent_integrity_failed', "
|
|
|
+ "'create_intent_validation_failed')) "
|
|
|
+ "AND (state = 'failed' OR (state = 'creating' "
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
+ "ORDER BY updated_at, id LIMIT :limit "
|
|
|
+ "/* list_reconcilable_dataflow_creates */"
|
|
|
+ ),
|
|
|
+ {"limit": limit},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ "reservation_id": str(row["reservation_id"]),
|
|
|
+ "dataflow_uid": str(row["dataflow_uid"]),
|
|
|
+ "state": str(row["state"]),
|
|
|
+ "attempt_count": int(row["attempt_count"]),
|
|
|
+ "request_digest": str(row["request_digest"]),
|
|
|
+ "error_code": (
|
|
|
+ str(row["error_code"]) if row["error_code"] is not None else None
|
|
|
+ ),
|
|
|
+ "lease_expires_at": (
|
|
|
+ row["lease_expires_at"].isoformat()
|
|
|
+ if hasattr(row["lease_expires_at"], "isoformat")
|
|
|
+ else row["lease_expires_at"]
|
|
|
+ ),
|
|
|
+ "updated_at": (
|
|
|
+ row["updated_at"].isoformat()
|
|
|
+ if hasattr(row["updated_at"], "isoformat")
|
|
|
+ else row["updated_at"]
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ for row in rows
|
|
|
+ ]
|
|
|
+
|
|
|
+ def claim_reconcilable_dataflow_creates(
|
|
|
+ self, *, limit: int = 100, lease_seconds: int = 60
|
|
|
+ ) -> list[dict[str, Any]]:
|
|
|
+ if (
|
|
|
+ isinstance(limit, bool)
|
|
|
+ or not isinstance(limit, int)
|
|
|
+ or limit < 1
|
|
|
+ or limit > 500
|
|
|
+ ):
|
|
|
+ raise ValueError("reconciliation limit is invalid")
|
|
|
+ if (
|
|
|
+ isinstance(lease_seconds, bool)
|
|
|
+ or not isinstance(lease_seconds, int)
|
|
|
+ or lease_seconds < 10
|
|
|
+ or lease_seconds > 300
|
|
|
+ ):
|
|
|
+ raise ValueError("reconciliation lease is invalid")
|
|
|
+ lease_token = new_governance_uid()
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "WITH candidates AS (SELECT id "
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
+ "WHERE request_digest IS NOT NULL "
|
|
|
+ "AND create_request IS NOT NULL "
|
|
|
+ "AND create_intent IS NOT NULL "
|
|
|
+ "AND (error_code IS NULL OR error_code NOT IN "
|
|
|
+ "('create_intent_integrity_failed', "
|
|
|
+ "'create_intent_validation_failed')) "
|
|
|
+ "AND (state = 'failed' OR (state = 'creating' "
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
+ "ORDER BY updated_at, id "
|
|
|
+ "FOR UPDATE SKIP LOCKED LIMIT :limit) "
|
|
|
+ "UPDATE public.dataflow_draft_reservations value "
|
|
|
+ "SET state = 'creating', "
|
|
|
+ "lease_token = CAST(:lease_token AS uuid), "
|
|
|
+ "lease_expires_at = CURRENT_TIMESTAMP + "
|
|
|
+ "(:lease_seconds * INTERVAL '1 second'), "
|
|
|
+ "attempt_count = attempt_count + 1, "
|
|
|
+ "error_code = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "FROM candidates WHERE value.id = candidates.id "
|
|
|
+ "RETURNING value.id::text AS reservation_id, "
|
|
|
+ "value.dataflow_uid::text, value.create_request, "
|
|
|
+ "value.create_intent, value.request_digest, "
|
|
|
+ "value.attempt_count "
|
|
|
+ "/* claim_reconcilable_dataflow_creates */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "limit": limit,
|
|
|
+ "lease_token": lease_token,
|
|
|
+ "lease_seconds": lease_seconds,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ claimed = []
|
|
|
+ for row in rows:
|
|
|
+ request_digest = str(row["request_digest"])
|
|
|
+ create_intent = None
|
|
|
+ integrity_error = None
|
|
|
+ try:
|
|
|
+ create_request = _bounded_object(
|
|
|
+ row["create_request"], "dataflow create request"
|
|
|
+ )
|
|
|
+ create_intent = _bounded_object(
|
|
|
+ row["create_intent"], "dataflow create intent"
|
|
|
+ )
|
|
|
+ integrity_valid = not (
|
|
|
+ _canonical_hash(create_request) != request_digest
|
|
|
+ or create_request.get("intent") != create_intent
|
|
|
+ or create_intent.get("dataflow_uid") != str(row["dataflow_uid"])
|
|
|
+ )
|
|
|
+ if not integrity_valid:
|
|
|
+ integrity_error = "dataflow create intent digest mismatch"
|
|
|
+ except Exception as exc:
|
|
|
+ integrity_valid = False
|
|
|
+ integrity_error = str(exc)
|
|
|
+ claimed.append(
|
|
|
+ {
|
|
|
+ "reservation_id": str(row["reservation_id"]),
|
|
|
+ "dataflow_uid": str(row["dataflow_uid"]),
|
|
|
+ "lease_token": lease_token,
|
|
|
+ "request_digest": request_digest,
|
|
|
+ "create_intent": create_intent,
|
|
|
+ "attempt": int(row["attempt_count"]),
|
|
|
+ "integrity_valid": integrity_valid,
|
|
|
+ "integrity_error": integrity_error,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return claimed
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def candidate_hash(candidate: dict[str, Any]) -> str:
|
|
|
+ return _canonical_hash(validate_rule_candidate(candidate))
|
|
|
+
|
|
|
+ def record_generation_run(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ evidence: dict[str, Any],
|
|
|
+ created_by: str | None = None,
|
|
|
+ validation_context: dict[str, Any] | None = None,
|
|
|
+ rule_version_id: str | None = None,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ if not isinstance(evidence, dict):
|
|
|
+ raise ValueError("generation evidence must be an object")
|
|
|
+ candidate = validate_rule_candidate(evidence.get("candidate"))
|
|
|
+ candidate_hash = self.candidate_hash(candidate)
|
|
|
+ if evidence.get("candidate_hash") != candidate_hash:
|
|
|
+ raise ValueError("generation candidate hash does not match")
|
|
|
+ decision = evidence.get("status")
|
|
|
+ if decision not in {
|
|
|
+ "ready",
|
|
|
+ "clarification_required",
|
|
|
+ "rejected",
|
|
|
+ "failed",
|
|
|
+ }:
|
|
|
+ raise ValueError("unsupported generation decision")
|
|
|
+ surface = evidence.get("authoring_surface")
|
|
|
+ if surface not in {"data_standard", "data_flow"}:
|
|
|
+ raise ValueError("unsupported authoring surface")
|
|
|
+ source = _text(evidence.get("source_text"), "source_text", 4_000)
|
|
|
+ version_id = (
|
|
|
+ _uid(rule_version_id, "rule_version_id")
|
|
|
+ if rule_version_id is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ context_hash = _digest(evidence.get("context_hash"), "context_hash")
|
|
|
+ confidence = candidate["confidence"]
|
|
|
+ repair_attempts = evidence.get("repair_attempts", 0)
|
|
|
+ if (
|
|
|
+ isinstance(repair_attempts, bool)
|
|
|
+ or not isinstance(repair_attempts, int)
|
|
|
+ or repair_attempts < 0
|
|
|
+ or repair_attempts > 2
|
|
|
+ ):
|
|
|
+ raise ValueError("repair_attempts must be between 0 and 2")
|
|
|
+ generation_id = new_governance_uid()
|
|
|
+ correlation_id = new_governance_uid()
|
|
|
+ actor = _uid(created_by, "created_by") if created_by is not None else None
|
|
|
+ if validation_context is not None:
|
|
|
+ if not isinstance(validation_context, dict) or set(validation_context) != {
|
|
|
+ "input_schema_snapshot_id",
|
|
|
+ "input_schema_hash",
|
|
|
+ "input_fields",
|
|
|
+ "output_schema_snapshot_id",
|
|
|
+ "output_schema_hash",
|
|
|
+ "output_fields",
|
|
|
+ "input_sample_artifact_ref",
|
|
|
+ "input_sample_artifact_digest",
|
|
|
+ "golden_output_artifact_ref",
|
|
|
+ "golden_output_artifact_digest",
|
|
|
+ }:
|
|
|
+ raise ValueError("validation context is invalid")
|
|
|
+ for prefix in ("input", "output"):
|
|
|
+ _uid(
|
|
|
+ validation_context[f"{prefix}_schema_snapshot_id"],
|
|
|
+ f"validation context {prefix} schema_snapshot_id",
|
|
|
+ )
|
|
|
+ _digest(
|
|
|
+ validation_context[f"{prefix}_schema_hash"],
|
|
|
+ f"validation context {prefix} schema_hash",
|
|
|
+ )
|
|
|
+ if not isinstance(validation_context[f"{prefix}_fields"], list):
|
|
|
+ raise ValueError(f"validation context {prefix} fields are invalid")
|
|
|
+ _digest(
|
|
|
+ validation_context["input_sample_artifact_digest"],
|
|
|
+ "input_sample_artifact_digest",
|
|
|
+ )
|
|
|
+ if validation_context["golden_output_artifact_ref"] is None:
|
|
|
+ if validation_context["golden_output_artifact_digest"] is not None:
|
|
|
+ raise ValueError("golden output artifact is invalid")
|
|
|
+ else:
|
|
|
+ _digest(
|
|
|
+ validation_context["golden_output_artifact_digest"],
|
|
|
+ "golden_output_artifact_digest",
|
|
|
+ )
|
|
|
+ attempts = evidence.get("generation_attempts") or []
|
|
|
+ if not isinstance(attempts, list) or len(attempts) > 3:
|
|
|
+ raise ValueError("generation attempts are invalid")
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_generation_runs "
|
|
|
+ "(id, rule_version_id, authoring_surface, source_text_hash, "
|
|
|
+ "model_provider, model_name, prompt_version, schema_version, "
|
|
|
+ "context_hash, candidate_hash, confidence, assumptions, "
|
|
|
+ "ambiguities, repair_attempts, decision, decision_detail, "
|
|
|
+ "correlation_id, created_by, candidate, validation_context, "
|
|
|
+ "model_hash, prompt_hash) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:rule_version_id AS uuid), "
|
|
|
+ ":authoring_surface, :source_text_hash, :model_provider, "
|
|
|
+ ":model_name, :prompt_version, :schema_version, :context_hash, "
|
|
|
+ ":candidate_hash, :confidence, CAST(:assumptions AS jsonb), "
|
|
|
+ "CAST(:ambiguities AS jsonb), :repair_attempts, :decision, "
|
|
|
+ "CAST(:decision_detail AS jsonb), CAST(:correlation_id AS uuid), "
|
|
|
+ "CAST(:created_by AS uuid), CAST(:candidate AS jsonb), "
|
|
|
+ "CAST(:validation_context AS jsonb), :model_hash, :prompt_hash)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": generation_id,
|
|
|
+ "rule_version_id": version_id,
|
|
|
+ "authoring_surface": surface,
|
|
|
+ "source_text_hash": hashlib.sha256(source.encode("utf-8")).hexdigest(),
|
|
|
+ "model_provider": _text(
|
|
|
+ evidence.get("model_provider"), "model_provider", 80
|
|
|
+ ),
|
|
|
+ "model_name": _text(evidence.get("model_name"), "model_name", 120),
|
|
|
+ "prompt_version": _text(
|
|
|
+ evidence.get("prompt_version"), "prompt_version", 80
|
|
|
+ ),
|
|
|
+ "schema_version": _text(
|
|
|
+ evidence.get("schema_version"), "schema_version", 40
|
|
|
+ ),
|
|
|
+ "context_hash": context_hash,
|
|
|
+ "candidate_hash": candidate_hash,
|
|
|
+ "confidence": confidence,
|
|
|
+ "assumptions": _json(candidate["assumptions"]),
|
|
|
+ "ambiguities": _json(candidate["ambiguities"]),
|
|
|
+ "repair_attempts": repair_attempts,
|
|
|
+ "decision": decision,
|
|
|
+ "decision_detail": _json({"explanation": candidate["explanation"]}),
|
|
|
+ "correlation_id": correlation_id,
|
|
|
+ "created_by": actor,
|
|
|
+ "candidate": _json(candidate),
|
|
|
+ "validation_context": (
|
|
|
+ _json(validation_context)
|
|
|
+ if validation_context is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "model_hash": _digest(evidence.get("model_hash"), "model_hash"),
|
|
|
+ "prompt_hash": _digest(evidence.get("prompt_hash"), "prompt_hash"),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ for expected_attempt, attempt in enumerate(attempts):
|
|
|
+ if not isinstance(attempt, dict) or set(attempt) != {
|
|
|
+ "attempt",
|
|
|
+ "status",
|
|
|
+ "candidate_hash",
|
|
|
+ "error_code",
|
|
|
+ "model_hash",
|
|
|
+ "prompt_hash",
|
|
|
+ "context_hash",
|
|
|
+ }:
|
|
|
+ raise ValueError("generation attempt is invalid")
|
|
|
+ if attempt["attempt"] != expected_attempt or attempt["status"] not in {
|
|
|
+ "valid",
|
|
|
+ "invalid",
|
|
|
+ }:
|
|
|
+ raise ValueError("generation attempt sequence is invalid")
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_generation_attempts "
|
|
|
+ "(id, generation_run_id, attempt_no, candidate_hash, "
|
|
|
+ "candidate, error_code, model_hash, prompt_hash, "
|
|
|
+ "context_hash, status) VALUES (CAST(:id AS uuid), "
|
|
|
+ "CAST(:generation_run_id AS uuid), :attempt_no, "
|
|
|
+ ":candidate_hash, CAST(:candidate AS jsonb), :error_code, "
|
|
|
+ ":model_hash, :prompt_hash, :context_hash, :status)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "generation_run_id": generation_id,
|
|
|
+ "attempt_no": expected_attempt,
|
|
|
+ "candidate_hash": _digest(
|
|
|
+ attempt["candidate_hash"], "attempt candidate_hash"
|
|
|
+ ),
|
|
|
+ "candidate": (
|
|
|
+ _json(candidate) if attempt["status"] == "valid" else None
|
|
|
+ ),
|
|
|
+ "error_code": attempt["error_code"],
|
|
|
+ "model_hash": _digest(attempt["model_hash"], "attempt model_hash"),
|
|
|
+ "prompt_hash": _digest(
|
|
|
+ attempt["prompt_hash"], "attempt prompt_hash"
|
|
|
+ ),
|
|
|
+ "context_hash": _digest(
|
|
|
+ attempt["context_hash"], "attempt context_hash"
|
|
|
+ ),
|
|
|
+ "status": attempt["status"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "id": generation_id,
|
|
|
+ "correlation_id": correlation_id,
|
|
|
+ "decision": decision,
|
|
|
+ "candidate_hash": candidate_hash,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _lock(self, aggregate_uid: str) -> None:
|
|
|
+ self.session.execute(
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
|
|
|
+ {"key": f"data-rule:{aggregate_uid}"},
|
|
|
+ )
|
|
|
+
|
|
|
+ def create_rule_version(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ rule_spec: dict[str, Any],
|
|
|
+ source_text: str,
|
|
|
+ created_by: str,
|
|
|
+ category: str = "general",
|
|
|
+ source_language: str = "zh-CN",
|
|
|
+ generated_kind: str = "rulespec",
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ spec = validate_rule_spec(rule_spec)
|
|
|
+ source = _text(source_text, "source_text", 20_000)
|
|
|
+ actor = _uid(created_by, "created_by")
|
|
|
+ language = _text(source_language, "source_language", 20)
|
|
|
+ if category not in RULE_CATEGORIES:
|
|
|
+ raise ValueError("unsupported rule category")
|
|
|
+ if generated_kind not in GENERATED_KINDS:
|
|
|
+ raise ValueError("unsupported generated kind")
|
|
|
+
|
|
|
+ rule_uid = spec["rule_uid"]
|
|
|
+ digest = rule_spec_hash(spec)
|
|
|
+ self._lock(rule_uid)
|
|
|
+ existing = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, version_no, status "
|
|
|
+ "FROM public.data_rule_versions "
|
|
|
+ "WHERE rule_uid = CAST(:rule_uid AS uuid) "
|
|
|
+ "AND spec_hash = :spec_hash "
|
|
|
+ "/* existing_version */"
|
|
|
+ ),
|
|
|
+ {"rule_uid": rule_uid, "spec_hash": digest},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return {
|
|
|
+ **dict(existing),
|
|
|
+ "rule_uid": rule_uid,
|
|
|
+ "spec_hash": digest,
|
|
|
+ "created": False,
|
|
|
+ }
|
|
|
+
|
|
|
+ version_no = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT COALESCE(MAX(version_no), 0) + 1 "
|
|
|
+ "FROM public.data_rule_versions "
|
|
|
+ "WHERE rule_uid = CAST(:rule_uid AS uuid)"
|
|
|
+ ),
|
|
|
+ {"rule_uid": rule_uid},
|
|
|
+ ).scalar_one()
|
|
|
+ version_id = new_governance_uid()
|
|
|
+ try:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.data_rules "
|
|
|
+ "(id, rule_uid, name, category, owner_uid, status) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid), "
|
|
|
+ ":name, :category, CAST(:owner_uid AS uuid), 'active') "
|
|
|
+ "ON CONFLICT (rule_uid) DO UPDATE SET "
|
|
|
+ "name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "rule_uid": rule_uid,
|
|
|
+ "name": spec["name"],
|
|
|
+ "category": category,
|
|
|
+ "owner_uid": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.data_rule_versions "
|
|
|
+ "(id, rule_uid, version_no, source_text, source_language, "
|
|
|
+ "rule_spec, spec_hash, generated_kind, status, created_by) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid), "
|
|
|
+ ":version_no, :source_text, :source_language, "
|
|
|
+ "CAST(:rule_spec AS jsonb), :spec_hash, :generated_kind, "
|
|
|
+ "'draft', CAST(:created_by AS uuid))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": version_id,
|
|
|
+ "rule_uid": rule_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "source_text": source,
|
|
|
+ "source_language": language,
|
|
|
+ "rule_spec": _json(spec),
|
|
|
+ "spec_hash": digest,
|
|
|
+ "generated_kind": generated_kind,
|
|
|
+ "created_by": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ except IntegrityError as exc:
|
|
|
+ raise ValueError("rule version conflicts with existing data") from exc
|
|
|
+ return {
|
|
|
+ "id": version_id,
|
|
|
+ "rule_uid": rule_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "status": "draft",
|
|
|
+ "spec_hash": digest,
|
|
|
+ "created": True,
|
|
|
+ }
|
|
|
+
|
|
|
+ def resolve_validation_context(self, context: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ """Resolve an authoring profile exclusively from server-owned rows."""
|
|
|
+
|
|
|
+ if not isinstance(context, dict) or set(context) != {
|
|
|
+ "input_schema_snapshot_id",
|
|
|
+ "output_schema_snapshot_id",
|
|
|
+ "input_sample_artifact_ref",
|
|
|
+ "golden_output_artifact_ref",
|
|
|
+ }:
|
|
|
+ raise ValueError("authoring validation context is invalid")
|
|
|
+
|
|
|
+ def snapshot(prefix: str) -> dict[str, Any]:
|
|
|
+ snapshot_id = _uid(
|
|
|
+ context.get(f"{prefix}_schema_snapshot_id"),
|
|
|
+ f"{prefix}_schema_snapshot_id",
|
|
|
+ )
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS schema_snapshot_id, "
|
|
|
+ "schema_hash, fields FROM "
|
|
|
+ "public.data_schema_snapshots "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "/* trusted_authoring_schema */"
|
|
|
+ ),
|
|
|
+ {"id": snapshot_id},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError(f"trusted {prefix} authoring schema was not found")
|
|
|
+ return {
|
|
|
+ "id": str(row["schema_snapshot_id"]),
|
|
|
+ "hash": str(row["schema_hash"]),
|
|
|
+ "fields": _array(row["fields"], f"{prefix} schema fields"),
|
|
|
+ }
|
|
|
+
|
|
|
+ input_schema = snapshot("input")
|
|
|
+ output_schema = snapshot("output")
|
|
|
+
|
|
|
+ def artifact(
|
|
|
+ key: str, schema_hash: str, *, required: bool
|
|
|
+ ) -> tuple[str | None, str | None]:
|
|
|
+ value = context.get(key)
|
|
|
+ if value is None and not required:
|
|
|
+ return None, None
|
|
|
+ ref = _text(value, key, 500)
|
|
|
+ digest = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT artifact_digest FROM public.rule_run_artifacts "
|
|
|
+ "WHERE artifact_ref = :artifact_ref "
|
|
|
+ "AND schema_hash = :schema_hash "
|
|
|
+ "AND artifact_kind IN ('input','output') "
|
|
|
+ "AND handoff_status = 'ready' "
|
|
|
+ "AND expires_at > CURRENT_TIMESTAMP "
|
|
|
+ "/* trusted_authoring_sample */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "artifact_ref": ref,
|
|
|
+ "schema_hash": schema_hash,
|
|
|
+ },
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if digest is None:
|
|
|
+ raise ValueError(f"trusted authoring artifact {key} was not found")
|
|
|
+ return ref, str(digest)
|
|
|
+
|
|
|
+ input_ref, input_digest = artifact(
|
|
|
+ "input_sample_artifact_ref",
|
|
|
+ input_schema["hash"],
|
|
|
+ required=True,
|
|
|
+ )
|
|
|
+ golden_ref, golden_digest = artifact(
|
|
|
+ "golden_output_artifact_ref",
|
|
|
+ output_schema["hash"],
|
|
|
+ required=False,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "input_schema_snapshot_id": input_schema["id"],
|
|
|
+ "input_schema_hash": input_schema["hash"],
|
|
|
+ "input_fields": input_schema["fields"],
|
|
|
+ "output_schema_snapshot_id": output_schema["id"],
|
|
|
+ "output_schema_hash": output_schema["hash"],
|
|
|
+ "output_fields": output_schema["fields"],
|
|
|
+ "input_sample_artifact_ref": input_ref,
|
|
|
+ "input_sample_artifact_digest": input_digest,
|
|
|
+ "golden_output_artifact_ref": golden_ref,
|
|
|
+ "golden_output_artifact_digest": golden_digest,
|
|
|
+ }
|
|
|
+
|
|
|
+ def create_draft_from_generation(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ rule_spec: dict[str, Any],
|
|
|
+ source_text: str,
|
|
|
+ created_by: str,
|
|
|
+ category: str,
|
|
|
+ source_language: str,
|
|
|
+ generated_kind: str,
|
|
|
+ receipt_claims: dict[str, Any],
|
|
|
+ receipt_hash: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ """Consume one ready generation run and atomically create its draft."""
|
|
|
+
|
|
|
+ actor = _uid(created_by, "created_by")
|
|
|
+ run_id = _uid(receipt_claims.get("generation_run_id"), "generation_run_id")
|
|
|
+ receipt_digest = _digest(receipt_hash, "receipt_hash")
|
|
|
+ generation = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, rule_version_id::text AS "
|
|
|
+ "rule_version_id, created_by::text AS created_by, "
|
|
|
+ "source_text_hash, candidate_hash, context_hash, "
|
|
|
+ "model_hash, prompt_hash, candidate, validation_context, "
|
|
|
+ "decision, receipt_hash, clock_timestamp() AS db_now "
|
|
|
+ "FROM public.rule_generation_runs "
|
|
|
+ "WHERE id = CAST(:id AS uuid) FOR UPDATE "
|
|
|
+ "/* consume_generation_receipt */"
|
|
|
+ ),
|
|
|
+ {"id": run_id},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if generation is None:
|
|
|
+ raise ValueError("generation receipt run was not found")
|
|
|
+ spec = validate_rule_spec(rule_spec)
|
|
|
+ source = _text(source_text, "source_text", 20_000)
|
|
|
+ validation_context = _object(
|
|
|
+ generation["validation_context"], "validation context"
|
|
|
+ )
|
|
|
+ expected = {
|
|
|
+ "actor_uid": actor,
|
|
|
+ "source_text_hash": hashlib.sha256(source.encode("utf-8")).hexdigest(),
|
|
|
+ "candidate_hash": str(generation["candidate_hash"]),
|
|
|
+ "rule_spec_hash": rule_spec_hash(spec),
|
|
|
+ "model_hash": str(generation["model_hash"]),
|
|
|
+ "prompt_hash": str(generation["prompt_hash"]),
|
|
|
+ "context_hash": str(generation["context_hash"]),
|
|
|
+ }
|
|
|
+ if any(receipt_claims.get(key) != value for key, value in expected.items()):
|
|
|
+ raise ValueError("generation receipt claims do not match audit")
|
|
|
+ candidate = validate_rule_candidate(generation["candidate"])
|
|
|
+ if (
|
|
|
+ generation["rule_version_id"] is not None
|
|
|
+ and str(generation["receipt_hash"] or "") == receipt_digest
|
|
|
+ and str(generation["created_by"] or "") == actor
|
|
|
+ and generation["decision"] == "ready"
|
|
|
+ and candidate["rule_spec"] == spec
|
|
|
+ ):
|
|
|
+ replay = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.id::text AS id, rv.rule_uid::text AS "
|
|
|
+ "rule_uid, rv.version_no, rv.status, rv.spec_hash, "
|
|
|
+ "vp.id::text AS validation_profile_id "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_validation_profiles vp "
|
|
|
+ "ON vp.rule_version_id = rv.id "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "/* generation_receipt_commit_unknown_replay */"
|
|
|
+ ),
|
|
|
+ {"id": str(generation["rule_version_id"])},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if replay is None:
|
|
|
+ raise ValueError("generation receipt replay state is incomplete")
|
|
|
+ return {
|
|
|
+ "id": str(replay["id"]),
|
|
|
+ "rule_uid": str(replay["rule_uid"]),
|
|
|
+ "version_no": int(replay["version_no"]),
|
|
|
+ "status": str(replay["status"]),
|
|
|
+ "spec_hash": str(replay["spec_hash"]),
|
|
|
+ "created": True,
|
|
|
+ "generation_run_id": run_id,
|
|
|
+ "validation_profile_id": str(replay["validation_profile_id"]),
|
|
|
+ }
|
|
|
+ db_now = generation["db_now"]
|
|
|
+ if not hasattr(db_now, "timestamp") or int(db_now.timestamp()) >= int(
|
|
|
+ receipt_claims.get("expires_at") or 0
|
|
|
+ ):
|
|
|
+ raise ValueError("generation receipt has expired")
|
|
|
+ if (
|
|
|
+ generation["decision"] != "ready"
|
|
|
+ or str(generation["created_by"] or "") != actor
|
|
|
+ or candidate["rule_spec"] != spec
|
|
|
+ or generation["rule_version_id"] is not None
|
|
|
+ or generation["receipt_hash"] is not None
|
|
|
+ ):
|
|
|
+ raise ValueError("generation receipt is not consumable")
|
|
|
+ created = self.create_rule_version(
|
|
|
+ rule_spec=spec,
|
|
|
+ source_text=source,
|
|
|
+ created_by=actor,
|
|
|
+ category=category,
|
|
|
+ source_language=source_language,
|
|
|
+ generated_kind=generated_kind,
|
|
|
+ )
|
|
|
+ if not created["created"]:
|
|
|
+ raise ValueError("generation RuleSpec already has a version")
|
|
|
+ profile_id = new_governance_uid()
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_validation_profiles "
|
|
|
+ "(id, rule_version_id, input_schema_snapshot_id, "
|
|
|
+ "input_schema_hash, input_fields, "
|
|
|
+ "output_schema_snapshot_id, output_schema_hash, "
|
|
|
+ "output_fields, input_sample_artifact_ref, "
|
|
|
+ "input_sample_artifact_digest, "
|
|
|
+ "golden_output_artifact_ref, "
|
|
|
+ "golden_output_artifact_digest, context_hash) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:rule_version_id AS uuid), "
|
|
|
+ "CAST(:input_schema_snapshot_id AS uuid), "
|
|
|
+ ":input_schema_hash, CAST(:input_fields AS jsonb), "
|
|
|
+ "CAST(:output_schema_snapshot_id AS uuid), "
|
|
|
+ ":output_schema_hash, CAST(:output_fields AS jsonb), "
|
|
|
+ ":input_sample_artifact_ref, "
|
|
|
+ ":input_sample_artifact_digest, "
|
|
|
+ ":golden_output_artifact_ref, "
|
|
|
+ ":golden_output_artifact_digest, :context_hash)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": profile_id,
|
|
|
+ "rule_version_id": created["id"],
|
|
|
+ "input_schema_snapshot_id": validation_context[
|
|
|
+ "input_schema_snapshot_id"
|
|
|
+ ],
|
|
|
+ "input_schema_hash": validation_context["input_schema_hash"],
|
|
|
+ "input_fields": _json(validation_context["input_fields"]),
|
|
|
+ "output_schema_snapshot_id": validation_context[
|
|
|
+ "output_schema_snapshot_id"
|
|
|
+ ],
|
|
|
+ "output_schema_hash": validation_context["output_schema_hash"],
|
|
|
+ "output_fields": _json(validation_context["output_fields"]),
|
|
|
+ "input_sample_artifact_ref": validation_context[
|
|
|
+ "input_sample_artifact_ref"
|
|
|
+ ],
|
|
|
+ "input_sample_artifact_digest": validation_context[
|
|
|
+ "input_sample_artifact_digest"
|
|
|
+ ],
|
|
|
+ "golden_output_artifact_ref": validation_context[
|
|
|
+ "golden_output_artifact_ref"
|
|
|
+ ],
|
|
|
+ "golden_output_artifact_digest": validation_context[
|
|
|
+ "golden_output_artifact_digest"
|
|
|
+ ],
|
|
|
+ "context_hash": str(generation["context_hash"]),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ linked = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.rule_generation_runs SET "
|
|
|
+ "rule_version_id = CAST(:rule_version_id AS uuid), "
|
|
|
+ "receipt_hash = :receipt_hash, "
|
|
|
+ "receipt_consumed_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND rule_version_id IS NULL AND receipt_hash IS NULL"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": run_id,
|
|
|
+ "rule_version_id": created["id"],
|
|
|
+ "receipt_hash": receipt_digest,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(linked, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("generation receipt was already consumed")
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=created["id"],
|
|
|
+ actor_uid=actor,
|
|
|
+ action="draft_created",
|
|
|
+ to_status="draft",
|
|
|
+ evidence_hash=receipt_digest,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ **created,
|
|
|
+ "generation_run_id": run_id,
|
|
|
+ "validation_profile_id": profile_id,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _audit_publication(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ version_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ action: str,
|
|
|
+ to_status: str,
|
|
|
+ plan_id: str | None = None,
|
|
|
+ from_status: str | None = None,
|
|
|
+ evidence_hash: str | None = None,
|
|
|
+ ) -> None:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_publication_audits "
|
|
|
+ "(id, rule_version_id, rule_execution_plan_id, actor_uid, "
|
|
|
+ "action, from_status, to_status, evidence_hash) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:version_id AS uuid), "
|
|
|
+ "CAST(:plan_id AS uuid), CAST(:actor_uid AS uuid), :action, "
|
|
|
+ ":from_status, :to_status, :evidence_hash)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "version_id": _uid(version_id, "version_id"),
|
|
|
+ "plan_id": (_uid(plan_id, "plan_id") if plan_id is not None else None),
|
|
|
+ "actor_uid": _uid(actor_uid, "actor_uid"),
|
|
|
+ "action": action,
|
|
|
+ "from_status": from_status,
|
|
|
+ "to_status": to_status,
|
|
|
+ "evidence_hash": evidence_hash,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ def load_logical_validation_context(self, *, version_id: str) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.id::text AS version_id, rv.rule_spec, "
|
|
|
+ "rv.spec_hash, rv.status, vp.id::text AS profile_id, "
|
|
|
+ "vp.input_schema_snapshot_id::text AS input_snapshot_id, "
|
|
|
+ "vp.input_schema_hash AS profile_input_hash, "
|
|
|
+ "vp.input_fields AS profile_input_fields, "
|
|
|
+ "vp.output_schema_snapshot_id::text AS output_snapshot_id, "
|
|
|
+ "vp.output_schema_hash AS profile_output_hash, "
|
|
|
+ "vp.output_fields AS profile_output_fields, "
|
|
|
+ "vp.input_sample_artifact_ref, "
|
|
|
+ "vp.input_sample_artifact_digest, "
|
|
|
+ "vp.golden_output_artifact_ref, "
|
|
|
+ "vp.golden_output_artifact_digest, vp.context_hash, "
|
|
|
+ "ins.schema_ref AS input_schema_ref, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "ins.fields AS input_fields, "
|
|
|
+ "ins.source_revision AS input_source_revision, "
|
|
|
+ "outs.schema_ref AS output_schema_ref, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "outs.fields AS output_fields, "
|
|
|
+ "outs.source_revision AS output_source_revision "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_validation_profiles vp "
|
|
|
+ "ON vp.rule_version_id = rv.id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = vp.input_schema_snapshot_id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = vp.output_schema_snapshot_id "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "/* logical_validation_context */"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("trusted logical validation profile was not found")
|
|
|
+ if row["status"] != "draft":
|
|
|
+ raise ValueError("only draft rule versions may be validated")
|
|
|
+ if (
|
|
|
+ str(row["profile_input_hash"]) != str(row["input_schema_hash"])
|
|
|
+ or str(row["profile_output_hash"]) != str(row["output_schema_hash"])
|
|
|
+ or _array(row["profile_input_fields"], "profile input fields")
|
|
|
+ != _array(row["input_fields"], "input fields")
|
|
|
+ or _array(row["profile_output_fields"], "profile output fields")
|
|
|
+ != _array(row["output_fields"], "output fields")
|
|
|
+ ):
|
|
|
+ raise ValueError("trusted validation schema has drifted")
|
|
|
+ return {
|
|
|
+ "version": {
|
|
|
+ "id": str(row["version_id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "rule_spec": _object(row["rule_spec"], "rule_spec"),
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ },
|
|
|
+ "profile": {
|
|
|
+ "id": str(row["profile_id"]),
|
|
|
+ "input_schema_snapshot_id": str(row["input_snapshot_id"]),
|
|
|
+ "input_schema_hash": str(row["input_schema_hash"]),
|
|
|
+ "input_schema_ref": str(row["input_schema_ref"]),
|
|
|
+ "input_fields": _array(row["input_fields"], "input fields"),
|
|
|
+ "input_source_revision": str(row["input_source_revision"]),
|
|
|
+ "output_schema_snapshot_id": str(row["output_snapshot_id"]),
|
|
|
+ "output_schema_hash": str(row["output_schema_hash"]),
|
|
|
+ "output_schema_ref": str(row["output_schema_ref"]),
|
|
|
+ "output_fields": _array(row["output_fields"], "output fields"),
|
|
|
+ "output_source_revision": str(row["output_source_revision"]),
|
|
|
+ "input_sample_artifact_ref": row["input_sample_artifact_ref"],
|
|
|
+ "input_sample_artifact_digest": str(
|
|
|
+ row["input_sample_artifact_digest"]
|
|
|
+ ),
|
|
|
+ "golden_output_artifact_ref": row["golden_output_artifact_ref"],
|
|
|
+ "golden_output_artifact_digest": row["golden_output_artifact_digest"],
|
|
|
+ "context_hash": str(row["context_hash"]),
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def record_validation_failure(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ version_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ error_code: str,
|
|
|
+ error_hash: str,
|
|
|
+ ) -> None:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_validation_attempts "
|
|
|
+ "(id, rule_version_id, actor_uid, error_code, error_hash) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
|
|
|
+ "CAST(:actor_uid AS uuid), :error_code, :error_hash)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "version_id": _uid(version_id, "version_id"),
|
|
|
+ "actor_uid": _uid(actor_uid, "actor_uid"),
|
|
|
+ "error_code": _text(error_code, "error_code", 80),
|
|
|
+ "error_hash": _digest(error_hash, "error_hash"),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ def persist_logical_compile(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ version_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ context: dict[str, Any],
|
|
|
+ compiled: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ if context["version"]["id"] != version:
|
|
|
+ raise ValueError("logical compile version does not match")
|
|
|
+ plan_id = new_governance_uid()
|
|
|
+ inserted = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_logical_plans "
|
|
|
+ "(id, rule_version_id, validation_profile_id, "
|
|
|
+ "compiler_version, backend, plan, plan_hash, "
|
|
|
+ "schema_hashes, capabilities, status) "
|
|
|
+ "SELECT CAST(:id AS uuid), rv.id, vp.id, "
|
|
|
+ ":compiler_version, :backend, CAST(:plan AS jsonb), "
|
|
|
+ ":plan_hash, CAST(:schema_hashes AS jsonb), "
|
|
|
+ "CAST(:capabilities AS jsonb), 'compiled' "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_validation_profiles vp "
|
|
|
+ "ON vp.rule_version_id = rv.id "
|
|
|
+ "WHERE rv.id = CAST(:version_id AS uuid) "
|
|
|
+ "AND rv.status = 'draft' "
|
|
|
+ "ON CONFLICT (rule_version_id, plan_hash) DO NOTHING "
|
|
|
+ "RETURNING id::text AS id, status"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": plan_id,
|
|
|
+ "version_id": version,
|
|
|
+ "compiler_version": compiled["compiler_version"],
|
|
|
+ "backend": compiled["backend"],
|
|
|
+ "plan": _json(compiled["plan"]),
|
|
|
+ "plan_hash": compiled["plan_hash"],
|
|
|
+ "schema_hashes": _json(compiled["schema_hashes"]),
|
|
|
+ "capabilities": _json(compiled["capabilities"]),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if inserted is None:
|
|
|
+ inserted = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, status, plan, schema_hashes, "
|
|
|
+ "capabilities, compiler_version, backend "
|
|
|
+ "FROM public.rule_logical_plans "
|
|
|
+ "WHERE rule_version_id = CAST(:version_id AS uuid) "
|
|
|
+ "AND plan_hash = :plan_hash"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "version_id": version,
|
|
|
+ "plan_hash": compiled["plan_hash"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if inserted is None:
|
|
|
+ raise ValueError("logical compile state changed concurrently")
|
|
|
+ plan_id = str(inserted["id"])
|
|
|
+ compiler_digest = _canonical_hash(
|
|
|
+ {
|
|
|
+ "compiler_version": compiled["compiler_version"],
|
|
|
+ "plan_hash": compiled["plan_hash"],
|
|
|
+ "capabilities": compiled["capabilities"],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ evidence_id = new_governance_uid()
|
|
|
+ evidence_values = {
|
|
|
+ "id": evidence_id,
|
|
|
+ "plan_id": plan_id,
|
|
|
+ "compiler_version": compiled["compiler_version"],
|
|
|
+ "compiler_digest": compiler_digest,
|
|
|
+ "plan_hash": compiled["plan_hash"],
|
|
|
+ "schema_hashes": _json(compiled["schema_hashes"]),
|
|
|
+ "capabilities": _json(compiled["capabilities"]),
|
|
|
+ "created_by": actor,
|
|
|
+ }
|
|
|
+ canonical_evidence_id = self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_logical_compile_evidence "
|
|
|
+ "(id, logical_plan_id, compiler_version, compiler_digest, "
|
|
|
+ "plan_hash, schema_hashes, capabilities, status, created_by) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:plan_id AS uuid), "
|
|
|
+ ":compiler_version, :compiler_digest, :plan_hash, "
|
|
|
+ "CAST(:schema_hashes AS jsonb), CAST(:capabilities AS jsonb), "
|
|
|
+ "'success', CAST(:created_by AS uuid)) "
|
|
|
+ "ON CONFLICT (logical_plan_id, compiler_digest) DO NOTHING "
|
|
|
+ "RETURNING id::text"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if canonical_evidence_id is None:
|
|
|
+ existing_evidence = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, compiler_version, "
|
|
|
+ "compiler_digest, plan_hash, schema_hashes, "
|
|
|
+ "capabilities, status, created_by::text AS created_by "
|
|
|
+ "FROM public.rule_logical_compile_evidence "
|
|
|
+ "WHERE logical_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND compiler_digest = :compiler_digest"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ existing_evidence is None
|
|
|
+ or str(existing_evidence["compiler_version"])
|
|
|
+ != compiled["compiler_version"]
|
|
|
+ or str(existing_evidence["compiler_digest"]) != compiler_digest
|
|
|
+ or str(existing_evidence["plan_hash"]) != compiled["plan_hash"]
|
|
|
+ or _object(existing_evidence["schema_hashes"], "schema hashes")
|
|
|
+ != compiled["schema_hashes"]
|
|
|
+ or _object(existing_evidence["capabilities"], "capabilities")
|
|
|
+ != compiled["capabilities"]
|
|
|
+ or str(existing_evidence["status"]) != "success"
|
|
|
+ or str(existing_evidence["created_by"]) != actor
|
|
|
+ ):
|
|
|
+ raise ValueError("logical compile evidence replay has drifted")
|
|
|
+ canonical_evidence_id = str(existing_evidence["id"])
|
|
|
+ evidence_id = str(canonical_evidence_id)
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=version,
|
|
|
+ actor_uid=actor,
|
|
|
+ action="compile_succeeded",
|
|
|
+ from_status="draft",
|
|
|
+ to_status="draft",
|
|
|
+ evidence_hash=compiler_digest,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "version_id": version,
|
|
|
+ "version_status": "draft",
|
|
|
+ "plan_id": plan_id,
|
|
|
+ "plan_status": str(inserted["status"]),
|
|
|
+ "plan_hash": compiled["plan_hash"],
|
|
|
+ "compile_evidence_id": evidence_id,
|
|
|
+ }
|
|
|
+
|
|
|
+ def load_logical_test_context(
|
|
|
+ self, *, version_id: str, plan_id: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ plan = _uid(plan_id, "plan_id")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.status AS version_status, lp.id::text AS plan_id, "
|
|
|
+ "lp.status AS plan_status, lp.plan, lp.plan_hash, "
|
|
|
+ "lp.schema_hashes, lp.capabilities, "
|
|
|
+ "vp.input_sample_artifact_ref, "
|
|
|
+ "vp.input_sample_artifact_digest, "
|
|
|
+ "vp.golden_output_artifact_ref, "
|
|
|
+ "vp.golden_output_artifact_digest, "
|
|
|
+ "vp.input_schema_hash AS profile_input_hash, "
|
|
|
+ "vp.output_schema_hash AS profile_output_hash, "
|
|
|
+ "ins.schema_hash AS current_input_hash, "
|
|
|
+ "outs.schema_hash AS current_output_hash "
|
|
|
+ "FROM public.rule_logical_plans lp "
|
|
|
+ "JOIN public.data_rule_versions rv "
|
|
|
+ "ON rv.id = lp.rule_version_id "
|
|
|
+ "JOIN public.rule_validation_profiles vp "
|
|
|
+ "ON vp.id = lp.validation_profile_id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = vp.input_schema_snapshot_id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = vp.output_schema_snapshot_id "
|
|
|
+ "WHERE rv.id = CAST(:version_id AS uuid) "
|
|
|
+ "AND lp.id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_compile_evidence ce "
|
|
|
+ "WHERE ce.logical_plan_id = lp.id "
|
|
|
+ "AND ce.status = 'success' "
|
|
|
+ "AND ce.plan_hash = lp.plan_hash) "
|
|
|
+ "/* logical_test_context */ FOR UPDATE OF lp, rv"
|
|
|
+ ),
|
|
|
+ {"version_id": version, "plan_id": plan},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("trusted logical compile evidence was not found")
|
|
|
+ if str(row["profile_input_hash"]) != str(row["current_input_hash"]) or str(
|
|
|
+ row["profile_output_hash"]
|
|
|
+ ) != str(row["current_output_hash"]):
|
|
|
+ raise ValueError("trusted validation schema has drifted")
|
|
|
+ if row["plan_status"] in {"tested", "published"} and row["version_status"] in {
|
|
|
+ "validated",
|
|
|
+ "published",
|
|
|
+ }:
|
|
|
+ terminal = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id FROM "
|
|
|
+ "public.rule_logical_test_evidence "
|
|
|
+ "WHERE logical_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND status = 'success' AND plan_hash = :plan_hash "
|
|
|
+ "ORDER BY created_at DESC LIMIT 1"
|
|
|
+ ),
|
|
|
+ {"plan_id": plan, "plan_hash": row["plan_hash"]},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if terminal is None:
|
|
|
+ raise ValueError("logical plan terminal evidence is missing")
|
|
|
+ return {
|
|
|
+ "terminal_result": {
|
|
|
+ "version_id": version,
|
|
|
+ "version_status": str(row["version_status"]),
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": str(row["plan_status"]),
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ "test_evidence_id": str(terminal["id"]),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if row["version_status"] != "draft" or row["plan_status"] != "compiled":
|
|
|
+ raise ValueError("logical plan is not ready for testing")
|
|
|
+ sample_ref = row["input_sample_artifact_ref"]
|
|
|
+ sample_digest = row["input_sample_artifact_digest"]
|
|
|
+ if not isinstance(sample_ref, str) or not sample_ref:
|
|
|
+ raise ValueError("trusted logical sample is missing or expired")
|
|
|
+ _digest(sample_digest, "sample_artifact_digest")
|
|
|
+ return {
|
|
|
+ "version_id": version,
|
|
|
+ "version_status": str(row["version_status"]),
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": str(row["plan_status"]),
|
|
|
+ "plan": _object(row["plan"], "logical plan"),
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ "schema_hashes": _object(row["schema_hashes"], "schema hashes"),
|
|
|
+ "binding_hashes": {},
|
|
|
+ "capabilities": _object(row["capabilities"], "capabilities"),
|
|
|
+ "input_sample_artifact_ref": sample_ref,
|
|
|
+ "input_sample_artifact_digest": str(sample_digest),
|
|
|
+ "golden_output_artifact_ref": row["golden_output_artifact_ref"],
|
|
|
+ "golden_output_artifact_digest": row["golden_output_artifact_digest"],
|
|
|
+ }
|
|
|
+
|
|
|
+ def persist_logical_test(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ version_id: str,
|
|
|
+ plan_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ context: dict[str, Any],
|
|
|
+ evidence: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ plan = _uid(plan_id, "plan_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ evidence_hash = _canonical_hash(evidence)
|
|
|
+ evidence_id = new_governance_uid()
|
|
|
+ evidence_values = {
|
|
|
+ "id": evidence_id,
|
|
|
+ "plan_id": plan,
|
|
|
+ "test_kind": evidence["test_kind"],
|
|
|
+ "evidence_hash": evidence_hash,
|
|
|
+ "run_id": evidence["run_id"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "schema_hashes": _json(context["schema_hashes"]),
|
|
|
+ "evidence": _json(evidence),
|
|
|
+ "created_by": actor,
|
|
|
+ }
|
|
|
+ canonical_evidence_id = self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_logical_test_evidence "
|
|
|
+ "(id, logical_plan_id, test_kind, evidence_hash, run_id, "
|
|
|
+ "plan_hash, schema_hashes, evidence, status, created_by) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:plan_id AS uuid), "
|
|
|
+ ":test_kind, :evidence_hash, CAST(:run_id AS uuid), "
|
|
|
+ ":plan_hash, CAST(:schema_hashes AS jsonb), "
|
|
|
+ "CAST(:evidence AS jsonb), 'success', "
|
|
|
+ "CAST(:created_by AS uuid)) "
|
|
|
+ "ON CONFLICT (logical_plan_id, test_kind, evidence_hash) "
|
|
|
+ "DO NOTHING RETURNING id::text"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if canonical_evidence_id is None:
|
|
|
+ existing_evidence = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, test_kind, evidence_hash, "
|
|
|
+ "run_id::text AS run_id, plan_hash, schema_hashes, "
|
|
|
+ "evidence, status, created_by::text AS created_by "
|
|
|
+ "FROM public.rule_logical_test_evidence "
|
|
|
+ "WHERE logical_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND test_kind = :test_kind "
|
|
|
+ "AND evidence_hash = :evidence_hash"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ existing_evidence is None
|
|
|
+ or str(existing_evidence["test_kind"]) != evidence["test_kind"]
|
|
|
+ or str(existing_evidence["evidence_hash"]) != evidence_hash
|
|
|
+ or str(existing_evidence["run_id"]) != evidence["run_id"]
|
|
|
+ or str(existing_evidence["plan_hash"]) != context["plan_hash"]
|
|
|
+ or _object(existing_evidence["schema_hashes"], "schema hashes")
|
|
|
+ != context["schema_hashes"]
|
|
|
+ or _object(existing_evidence["evidence"], "test evidence") != evidence
|
|
|
+ or str(existing_evidence["status"]) != "success"
|
|
|
+ or str(existing_evidence["created_by"]) != actor
|
|
|
+ ):
|
|
|
+ raise ValueError("logical test evidence replay has drifted")
|
|
|
+ canonical_evidence_id = str(existing_evidence["id"])
|
|
|
+ evidence_id = str(canonical_evidence_id)
|
|
|
+ plan_update = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.rule_logical_plans SET status = 'tested' "
|
|
|
+ "WHERE id = CAST(:plan_id AS uuid) AND status = 'compiled' "
|
|
|
+ "AND plan_hash = :plan_hash"
|
|
|
+ ),
|
|
|
+ {"plan_id": plan, "plan_hash": context["plan_hash"]},
|
|
|
+ )
|
|
|
+ version_update = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.data_rule_versions SET status = 'validated' "
|
|
|
+ "WHERE id = CAST(:version_id AS uuid) AND status = 'draft'"
|
|
|
+ ),
|
|
|
+ {"version_id": version},
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ getattr(plan_update, "rowcount", 0) != 1
|
|
|
+ or getattr(version_update, "rowcount", 0) != 1
|
|
|
+ ):
|
|
|
+ raise ValueError("logical test state changed concurrently")
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=version,
|
|
|
+ actor_uid=actor,
|
|
|
+ action="test_succeeded",
|
|
|
+ from_status="draft",
|
|
|
+ to_status="validated",
|
|
|
+ evidence_hash=evidence_hash,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "version_id": version,
|
|
|
+ "version_status": "validated",
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": "tested",
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "test_evidence_id": evidence_id,
|
|
|
+ "test_evidence": copy.deepcopy(evidence),
|
|
|
+ }
|
|
|
+
|
|
|
+ def publish_validated_version(
|
|
|
+ self, *, version_id: str, actor_uid: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ from app.core.data_rules.compilers.polars import (
|
|
|
+ bound_polars_plan_hash,
|
|
|
+ validate_bound_polars_plan,
|
|
|
+ )
|
|
|
+
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.status AS version_status, rv.rule_uid::text "
|
|
|
+ "AS rule_uid, rv.version_no, rv.rule_spec, rv.spec_hash, "
|
|
|
+ "lp.id::text AS plan_id, lp.status AS plan_status, "
|
|
|
+ "lp.compiler_version, lp.backend, lp.plan, lp.plan_hash, "
|
|
|
+ "lp.schema_hashes, lp.capabilities, "
|
|
|
+ "vp.id::text AS profile_id, "
|
|
|
+ "vp.input_schema_snapshot_id::text AS input_snapshot_id, "
|
|
|
+ "vp.input_schema_hash AS profile_input_hash, "
|
|
|
+ "vp.input_fields AS profile_input_fields, "
|
|
|
+ "vp.output_schema_snapshot_id::text AS output_snapshot_id, "
|
|
|
+ "vp.output_schema_hash AS profile_output_hash, "
|
|
|
+ "vp.output_fields AS profile_output_fields, "
|
|
|
+ "ins.schema_ref AS input_schema_ref, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "ins.fields AS input_fields, "
|
|
|
+ "outs.schema_ref AS output_schema_ref, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "outs.fields AS output_fields "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_logical_plans lp "
|
|
|
+ "ON lp.rule_version_id = rv.id "
|
|
|
+ "JOIN public.rule_validation_profiles vp "
|
|
|
+ "ON vp.id = lp.validation_profile_id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = vp.input_schema_snapshot_id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = vp.output_schema_snapshot_id "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "ORDER BY lp.created_at DESC LIMIT 1 "
|
|
|
+ "FOR UPDATE OF rv, lp, vp, ins, outs "
|
|
|
+ "/* publication_gate_state */"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("rule version was not found")
|
|
|
+ spec = validate_rule_spec(_object(row["rule_spec"], "rule_spec"))
|
|
|
+ plan = validate_bound_polars_plan(_object(row["plan"], "logical plan"))
|
|
|
+ schema_hashes = _object(row["schema_hashes"], "schema hashes")
|
|
|
+ capabilities = _object(row["capabilities"], "capabilities")
|
|
|
+ input_fields = _array(row["input_fields"], "input fields")
|
|
|
+ output_fields = _array(row["output_fields"], "output fields")
|
|
|
+ expected_schema_hashes = {
|
|
|
+ "input": str(row["input_schema_hash"]),
|
|
|
+ "output": str(row["output_schema_hash"]),
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ str(row["backend"]) != "polars_batch"
|
|
|
+ or str(row["spec_hash"]) != rule_spec_hash(spec)
|
|
|
+ or spec["input_schema_ref"] != str(row["input_schema_ref"])
|
|
|
+ or spec["output_schema_ref"] != str(row["output_schema_ref"])
|
|
|
+ or str(row["profile_input_hash"]) != str(row["input_schema_hash"])
|
|
|
+ or str(row["profile_output_hash"]) != str(row["output_schema_hash"])
|
|
|
+ or _array(row["profile_input_fields"], "profile input fields")
|
|
|
+ != input_fields
|
|
|
+ or _array(row["profile_output_fields"], "profile output fields")
|
|
|
+ != output_fields
|
|
|
+ or plan["rule_version_id"] != version
|
|
|
+ or plan["rule_spec_hash"] != str(row["spec_hash"])
|
|
|
+ or plan["input_schema_snapshot_id"] != str(row["input_snapshot_id"])
|
|
|
+ or plan["input_schema_hash"] != str(row["input_schema_hash"])
|
|
|
+ or plan["input_fields"] != input_fields
|
|
|
+ or plan["output_schema_snapshot_id"] != str(row["output_snapshot_id"])
|
|
|
+ or plan["output_schema_hash"] != str(row["output_schema_hash"])
|
|
|
+ or plan["output_fields"] != output_fields
|
|
|
+ or plan["input_binding_id"] != str(row["profile_id"])
|
|
|
+ or plan["output_binding_id"] != version
|
|
|
+ or str(row["compiler_version"]) != plan["compiler_version"]
|
|
|
+ or str(row["plan_hash"]) != bound_polars_plan_hash(plan)
|
|
|
+ or schema_hashes != expected_schema_hashes
|
|
|
+ ):
|
|
|
+ raise ValueError("logical publication canonical state has drifted")
|
|
|
+ trusted = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_compile_evidence ce "
|
|
|
+ "WHERE ce.logical_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND ce.status = 'success' "
|
|
|
+ "AND ce.compiler_version = :compiler_version "
|
|
|
+ "AND ce.plan_hash = :plan_hash "
|
|
|
+ "AND ce.schema_hashes = CAST(:schema_hashes AS jsonb) "
|
|
|
+ "AND ce.capabilities = CAST(:capabilities AS jsonb)) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_test_evidence te "
|
|
|
+ "WHERE te.logical_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND te.status = 'success' AND te.plan_hash = :plan_hash "
|
|
|
+ "AND te.schema_hashes = CAST(:schema_hashes AS jsonb))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "plan_id": row["plan_id"],
|
|
|
+ "compiler_version": row["compiler_version"],
|
|
|
+ "plan_hash": row["plan_hash"],
|
|
|
+ "schema_hashes": _json(schema_hashes),
|
|
|
+ "capabilities": _json(capabilities),
|
|
|
+ },
|
|
|
+ ).scalar_one()
|
|
|
+ if not trusted:
|
|
|
+ raise ValueError("compile and test evidence has drifted")
|
|
|
+ if row["version_status"] == "published":
|
|
|
+ publication_actor = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT actor_uid::text FROM "
|
|
|
+ "public.rule_publication_audits "
|
|
|
+ "WHERE rule_version_id = CAST(:id AS uuid) "
|
|
|
+ "AND action = 'published' AND to_status = 'published' "
|
|
|
+ "ORDER BY created_at DESC LIMIT 1"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if (
|
|
|
+ row["plan_status"] == "published"
|
|
|
+ and str(publication_actor or "") == actor
|
|
|
+ ):
|
|
|
+ return {
|
|
|
+ "id": version,
|
|
|
+ "rule_uid": str(row["rule_uid"]),
|
|
|
+ "version_no": int(row["version_no"]),
|
|
|
+ "status": "published",
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ "plan_id": str(row["plan_id"]),
|
|
|
+ "plan_status": "published",
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ }
|
|
|
+ raise ValueError("rule version is already published and immutable")
|
|
|
+ if row["version_status"] != "validated" or row["plan_status"] != "tested":
|
|
|
+ raise ValueError("compile and test evidence is required")
|
|
|
+ plan_update = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.rule_logical_plans SET status = 'published' "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'tested'"
|
|
|
+ ),
|
|
|
+ {"id": row["plan_id"]},
|
|
|
+ )
|
|
|
+ version_update = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.data_rule_versions "
|
|
|
+ "SET status = 'published', published_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'validated'"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ getattr(plan_update, "rowcount", 0) != 1
|
|
|
+ or getattr(version_update, "rowcount", 0) != 1
|
|
|
+ ):
|
|
|
+ raise ValueError("rule publication state changed concurrently")
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=version,
|
|
|
+ actor_uid=actor,
|
|
|
+ action="published",
|
|
|
+ from_status="validated",
|
|
|
+ to_status="published",
|
|
|
+ evidence_hash=str(row["plan_hash"]),
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "id": version,
|
|
|
+ "rule_uid": str(row["rule_uid"]),
|
|
|
+ "version_no": int(row["version_no"]),
|
|
|
+ "status": "published",
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ "plan_id": str(row["plan_id"]),
|
|
|
+ "plan_status": "published",
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ def get_publication_evidence(self, *, version_id: str) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.status AS version_status, lp.id::text AS "
|
|
|
+ "plan_id, lp.status AS plan_status, lp.plan_hash, "
|
|
|
+ "lp.compiler_version, lp.schema_hashes, lp.capabilities, "
|
|
|
+ "ce.id::text AS compile_evidence_id, "
|
|
|
+ "ce.status AS compile_status, "
|
|
|
+ "te.id::text AS test_evidence_id, "
|
|
|
+ "te.status AS test_status, te.test_kind, te.evidence "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "LEFT JOIN public.rule_logical_plans lp "
|
|
|
+ "ON lp.rule_version_id = rv.id "
|
|
|
+ "LEFT JOIN public.rule_logical_compile_evidence ce "
|
|
|
+ "ON ce.logical_plan_id = lp.id "
|
|
|
+ "LEFT JOIN public.rule_logical_test_evidence te "
|
|
|
+ "ON te.logical_plan_id = lp.id "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "ORDER BY lp.created_at DESC, te.created_at DESC LIMIT 1"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("rule version was not found")
|
|
|
+ return {
|
|
|
+ "version_id": version,
|
|
|
+ "version_status": str(row["version_status"]),
|
|
|
+ "plan": (
|
|
|
+ {
|
|
|
+ "id": str(row["plan_id"]),
|
|
|
+ "status": str(row["plan_status"]),
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ "compiler_version": str(row["compiler_version"]),
|
|
|
+ "schema_hashes": _object(row["schema_hashes"], "schema hashes"),
|
|
|
+ "capabilities": _object(row["capabilities"], "capabilities"),
|
|
|
+ }
|
|
|
+ if row["plan_id"] is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "compile_evidence": (
|
|
|
+ {
|
|
|
+ "id": str(row["compile_evidence_id"]),
|
|
|
+ "status": str(row["compile_status"]),
|
|
|
+ }
|
|
|
+ if row["compile_evidence_id"] is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "test_evidence": (
|
|
|
+ {
|
|
|
+ "id": str(row["test_evidence_id"]),
|
|
|
+ "status": str(row["test_status"]),
|
|
|
+ "test_kind": str(row["test_kind"]),
|
|
|
+ "result": _object(row["evidence"], "test evidence"),
|
|
|
+ }
|
|
|
+ if row["test_evidence_id"] is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+ def search_published_rules(self, *, query: str, limit: int) -> list[dict[str, Any]]:
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.id::text AS id, rv.rule_uid::text AS rule_uid, "
|
|
|
+ "rv.version_no, r.name, r.category, rv.spec_hash, "
|
|
|
+ "lp.plan_hash, lp.backend, lp.capabilities "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.data_rules r ON r.rule_uid = rv.rule_uid "
|
|
|
+ "JOIN public.rule_logical_plans lp "
|
|
|
+ "ON lp.rule_version_id = rv.id "
|
|
|
+ "WHERE rv.status = 'published' "
|
|
|
+ "AND lp.status = 'published' "
|
|
|
+ "AND (:query = '' OR r.name ILIKE :pattern) "
|
|
|
+ "ORDER BY rv.published_at DESC, rv.id LIMIT :limit"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "query": query,
|
|
|
+ "pattern": f"%{query}%",
|
|
|
+ "limit": limit,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "rule_uid": str(row["rule_uid"]),
|
|
|
+ "version_no": int(row["version_no"]),
|
|
|
+ "name": str(row["name"]),
|
|
|
+ "category": str(row["category"]),
|
|
|
+ "status": "published",
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ "execution": {
|
|
|
+ "logical_plan_hash": str(row["plan_hash"]),
|
|
|
+ "backend": str(row["backend"]),
|
|
|
+ "capabilities": _object(row["capabilities"], "capabilities"),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for row in rows
|
|
|
+ ]
|
|
|
+
|
|
|
+ def search_published_assets(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ query: str = "",
|
|
|
+ asset_type: str | None = None,
|
|
|
+ limit: int = 50,
|
|
|
+ offset: int = 0,
|
|
|
+ input_schema_refs: list[str] | None = None,
|
|
|
+ output_schema_ref: str | None = None,
|
|
|
+ version_id: str | None = None,
|
|
|
+ schema_resolver: Any | None = None,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ """Return only immutable, trusted rule and standard versions."""
|
|
|
+ if not isinstance(query, str) or len(query) > 200:
|
|
|
+ raise ValueError("catalog query is invalid")
|
|
|
+ if asset_type not in {None, "rule", "standard"}:
|
|
|
+ raise ValueError("catalog asset_type is invalid")
|
|
|
+ if (
|
|
|
+ isinstance(limit, bool)
|
|
|
+ or not isinstance(limit, int)
|
|
|
+ or limit < 1
|
|
|
+ or limit > 100
|
|
|
+ ):
|
|
|
+ raise ValueError("catalog limit is invalid")
|
|
|
+ if (
|
|
|
+ isinstance(offset, bool)
|
|
|
+ or not isinstance(offset, int)
|
|
|
+ or offset < 0
|
|
|
+ or offset > 1_000_000
|
|
|
+ ):
|
|
|
+ raise ValueError("catalog offset is invalid")
|
|
|
+ normalized_query = query.strip()
|
|
|
+ has_context = input_schema_refs is not None or output_schema_ref is not None
|
|
|
+ if has_context:
|
|
|
+ if (
|
|
|
+ not isinstance(input_schema_refs, list)
|
|
|
+ or not input_schema_refs
|
|
|
+ or len(input_schema_refs) > 50
|
|
|
+ or any(
|
|
|
+ not isinstance(item, str) or not item.strip() or len(item) > 500
|
|
|
+ for item in input_schema_refs
|
|
|
+ )
|
|
|
+ or not isinstance(output_schema_ref, str)
|
|
|
+ or not output_schema_ref.strip()
|
|
|
+ or len(output_schema_ref) > 500
|
|
|
+ ):
|
|
|
+ raise ValueError("catalog schema context is invalid")
|
|
|
+ input_schema_refs = [item.strip() for item in input_schema_refs]
|
|
|
+ output_schema_ref = output_schema_ref.strip()
|
|
|
+ exact_version = (
|
|
|
+ _uid(version_id, "version_id") if version_id is not None else None
|
|
|
+ )
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "WITH catalog_source AS ("
|
|
|
+ "SELECT 'rule'::text AS asset_type, rv.id::text AS "
|
|
|
+ "version_id, rv.rule_uid::text AS asset_uid, r.name, "
|
|
|
+ "rv.version_no, r.owner_uid::text AS owner_uid, "
|
|
|
+ "rv.status, rv.rule_spec AS schema_compatibility, "
|
|
|
+ "lp.id::text AS logical_plan_id, lp.schema_hashes, "
|
|
|
+ "'[]'::jsonb AS bound_rules, 1::integer AS binding_count, "
|
|
|
+ "((SELECT COUNT(*) FROM "
|
|
|
+ "public.dataflow_component_bindings cb "
|
|
|
+ "WHERE cb.rule_version_id = rv.id) + "
|
|
|
+ "(SELECT COUNT(*) FROM public.standard_rule_bindings srb "
|
|
|
+ "WHERE srb.rule_version_id = rv.id))::integer "
|
|
|
+ "AS impact_count, lp.backend, "
|
|
|
+ "lce.id::text AS compile_evidence_id, "
|
|
|
+ "lce.status AS compile_status, "
|
|
|
+ "lte.id::text AS test_evidence_id, "
|
|
|
+ "lte.status AS test_status, rv.published_at "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.data_rules r ON r.rule_uid = rv.rule_uid "
|
|
|
+ "JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_logical_plans value "
|
|
|
+ "WHERE value.rule_version_id = rv.id "
|
|
|
+ "AND value.status = 'published' "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lp ON TRUE "
|
|
|
+ "JOIN LATERAL (SELECT value.id, value.status FROM "
|
|
|
+ "public.rule_logical_compile_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = lp.id "
|
|
|
+ "AND value.status = 'success' "
|
|
|
+ "AND value.compiler_version = lp.compiler_version "
|
|
|
+ "AND value.plan_hash = lp.plan_hash "
|
|
|
+ "AND value.schema_hashes = lp.schema_hashes "
|
|
|
+ "AND value.capabilities = lp.capabilities "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lce ON TRUE "
|
|
|
+ "JOIN LATERAL (SELECT value.id, value.status FROM "
|
|
|
+ "public.rule_logical_test_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = lp.id "
|
|
|
+ "AND value.status = 'success' "
|
|
|
+ "AND value.plan_hash = lp.plan_hash "
|
|
|
+ "AND value.schema_hashes = lp.schema_hashes "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lte ON TRUE "
|
|
|
+ "WHERE rv.status = 'published' "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_publication_audits pa "
|
|
|
+ "WHERE pa.rule_version_id = rv.id "
|
|
|
+ "AND pa.action = 'published' "
|
|
|
+ "AND pa.to_status = 'published' "
|
|
|
+ "AND pa.evidence_hash = lp.plan_hash) "
|
|
|
+ "UNION ALL "
|
|
|
+ "SELECT 'standard'::text AS asset_type, "
|
|
|
+ "sv.id::text AS version_id, "
|
|
|
+ "sv.standard_uid::text AS asset_uid, s.name, "
|
|
|
+ "sv.version_no, s.owner_uid::text AS owner_uid, "
|
|
|
+ "sv.status, sv.scope AS schema_compatibility, "
|
|
|
+ "NULL::text AS logical_plan_id, NULL::jsonb AS schema_hashes, "
|
|
|
+ "COALESCE((SELECT jsonb_agg(jsonb_build_object("
|
|
|
+ "'rule_version_id', brv.id::text, "
|
|
|
+ "'rule_spec', brv.rule_spec, "
|
|
|
+ "'logical_plan_id', blp.id::text, "
|
|
|
+ "'schema_hashes', blp.schema_hashes, "
|
|
|
+ "'compile_evidence_id', bce.id::text, "
|
|
|
+ "'test_evidence_id', bte.id::text) ORDER BY srb.clause_id) "
|
|
|
+ "FROM public.standard_rule_bindings srb "
|
|
|
+ "JOIN public.data_rule_versions brv "
|
|
|
+ "ON brv.id = srb.rule_version_id "
|
|
|
+ "JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_logical_plans value "
|
|
|
+ "WHERE value.rule_version_id = brv.id "
|
|
|
+ "AND value.status = 'published' "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) blp ON TRUE "
|
|
|
+ "JOIN LATERAL (SELECT value.id FROM "
|
|
|
+ "public.rule_logical_compile_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = blp.id "
|
|
|
+ "AND value.status = 'success' "
|
|
|
+ "AND value.compiler_version = blp.compiler_version "
|
|
|
+ "AND value.plan_hash = blp.plan_hash "
|
|
|
+ "AND value.schema_hashes = blp.schema_hashes "
|
|
|
+ "AND value.capabilities = blp.capabilities "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) bce ON TRUE "
|
|
|
+ "JOIN LATERAL (SELECT value.id FROM "
|
|
|
+ "public.rule_logical_test_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = blp.id "
|
|
|
+ "AND value.status = 'success' "
|
|
|
+ "AND value.plan_hash = blp.plan_hash "
|
|
|
+ "AND value.schema_hashes = blp.schema_hashes "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) bte ON TRUE "
|
|
|
+ "WHERE srb.standard_version_id = sv.id "
|
|
|
+ "AND brv.status = 'published'), '[]'::jsonb) AS bound_rules, "
|
|
|
+ "(SELECT COUNT(*) FROM public.standard_rule_bindings srb "
|
|
|
+ "WHERE srb.standard_version_id = sv.id)::integer "
|
|
|
+ "AS binding_count, "
|
|
|
+ "(SELECT COUNT(*) FROM "
|
|
|
+ "public.dataflow_component_bindings cb "
|
|
|
+ "WHERE cb.standard_version_id = sv.id)::integer "
|
|
|
+ "AS impact_count, 'composite'::text AS backend, "
|
|
|
+ "NULL::text AS compile_evidence_id, "
|
|
|
+ "'success'::text AS compile_status, "
|
|
|
+ "NULL::text AS test_evidence_id, "
|
|
|
+ "'success'::text AS test_status, sv.published_at "
|
|
|
+ "FROM public.data_standard_versions sv "
|
|
|
+ "JOIN public.data_standards s "
|
|
|
+ "ON s.standard_uid = sv.standard_uid "
|
|
|
+ "WHERE sv.status = 'published' "
|
|
|
+ "AND NOT EXISTS (SELECT 1 FROM "
|
|
|
+ "public.standard_rule_bindings srb "
|
|
|
+ "LEFT JOIN public.data_rule_versions bound_rv "
|
|
|
+ "ON bound_rv.id = srb.rule_version_id "
|
|
|
+ "WHERE srb.standard_version_id = sv.id "
|
|
|
+ "AND (bound_rv.id IS NULL "
|
|
|
+ "OR bound_rv.status <> 'published'))"
|
|
|
+ "), filtered AS ("
|
|
|
+ "SELECT * FROM catalog_source "
|
|
|
+ "WHERE (:asset_type IS NULL "
|
|
|
+ "OR asset_type = :asset_type) "
|
|
|
+ "AND (:version_id IS NULL "
|
|
|
+ "OR version_id = :version_id) "
|
|
|
+ "AND (:query = '' OR name ILIKE :pattern "
|
|
|
+ "OR asset_uid ILIKE :pattern "
|
|
|
+ "OR owner_uid ILIKE :pattern)"
|
|
|
+ "), page AS ("
|
|
|
+ "SELECT * FROM filtered "
|
|
|
+ "ORDER BY published_at DESC NULLS LAST, "
|
|
|
+ "asset_type, asset_uid, version_no DESC "
|
|
|
+ "LIMIT :limit OFFSET :offset"
|
|
|
+ "), counted AS (SELECT COUNT(*)::integer AS total_count "
|
|
|
+ "FROM filtered) "
|
|
|
+ "SELECT page.*, counted.total_count FROM counted "
|
|
|
+ "LEFT JOIN page ON TRUE "
|
|
|
+ "/* unified_published_asset_catalog */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "query": normalized_query,
|
|
|
+ "pattern": f"%{normalized_query}%",
|
|
|
+ "asset_type": asset_type,
|
|
|
+ "version_id": exact_version,
|
|
|
+ "limit": limit,
|
|
|
+ "offset": offset,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+
|
|
|
+ def evidence_summary(row: Any, prefix: str) -> dict[str, Any] | None:
|
|
|
+ status = row[f"{prefix}_status"]
|
|
|
+ if status is None:
|
|
|
+ return None
|
|
|
+ result = {"status": str(status)}
|
|
|
+ evidence_id = row[f"{prefix}_evidence_id"]
|
|
|
+ if evidence_id is not None:
|
|
|
+ result["id"] = str(evidence_id)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def resolve_ref(schema_ref: Any) -> dict[str, Any] | None:
|
|
|
+ if schema_resolver is None or not isinstance(schema_ref, str):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ snapshot = schema_resolver.resolve(schema_ref)
|
|
|
+ except Exception:
|
|
|
+ return None
|
|
|
+ if not isinstance(snapshot, dict):
|
|
|
+ return None
|
|
|
+ schema_hash = snapshot.get("schema_hash")
|
|
|
+ if not isinstance(schema_hash, str) or not re.fullmatch(
|
|
|
+ r"[0-9a-f]{64}", schema_hash
|
|
|
+ ):
|
|
|
+ return None
|
|
|
+ return {
|
|
|
+ "snapshot_id": (
|
|
|
+ str(snapshot["id"]) if snapshot.get("id") is not None else None
|
|
|
+ ),
|
|
|
+ "schema_hash": schema_hash,
|
|
|
+ "source_revision": (
|
|
|
+ str(snapshot["source_revision"])
|
|
|
+ if snapshot.get("source_revision") is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+ def rule_compatibility(
|
|
|
+ binding: dict[str, Any],
|
|
|
+ published_hashes: Any,
|
|
|
+ logical_plan_id: Any,
|
|
|
+ compile_evidence_id: Any,
|
|
|
+ test_evidence_id: Any,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ asset_input = binding.get("input_schema_ref")
|
|
|
+ asset_output = binding.get("output_schema_ref")
|
|
|
+ hashes = (
|
|
|
+ _object(published_hashes, "schema hashes")
|
|
|
+ if published_hashes is not None
|
|
|
+ else {}
|
|
|
+ )
|
|
|
+ input_snapshot = resolve_ref(asset_input)
|
|
|
+ output_snapshot = resolve_ref(asset_output)
|
|
|
+ refs_match = (
|
|
|
+ asset_input in input_schema_refs and asset_output == output_schema_ref
|
|
|
+ )
|
|
|
+ if input_snapshot is None or output_snapshot is None:
|
|
|
+ status, reason = "unknown", "schema_resolution_unavailable"
|
|
|
+ else:
|
|
|
+ hashes_match = input_snapshot["schema_hash"] == hashes.get(
|
|
|
+ "input"
|
|
|
+ ) and output_snapshot["schema_hash"] == hashes.get("output")
|
|
|
+ status = "compatible" if refs_match and hashes_match else "incompatible"
|
|
|
+ reason = (
|
|
|
+ "trusted_schema_hashes_match"
|
|
|
+ if status == "compatible"
|
|
|
+ else "schema_ref_or_hash_drift"
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "status": status,
|
|
|
+ "reason": reason,
|
|
|
+ "evidence": {
|
|
|
+ "logical_plan_id": (
|
|
|
+ str(logical_plan_id) if logical_plan_id is not None else None
|
|
|
+ ),
|
|
|
+ "compile_evidence_id": (
|
|
|
+ str(compile_evidence_id)
|
|
|
+ if compile_evidence_id is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "test_evidence_id": (
|
|
|
+ str(test_evidence_id) if test_evidence_id is not None else None
|
|
|
+ ),
|
|
|
+ "input": input_snapshot,
|
|
|
+ "output": output_snapshot,
|
|
|
+ "published_schema_hashes": {
|
|
|
+ "input": hashes.get("input"),
|
|
|
+ "output": hashes.get("output"),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def compatibility_summary(value: Any, row: Any) -> dict[str, Any]:
|
|
|
+ binding = _object(value, "schema compatibility")
|
|
|
+ if not has_context:
|
|
|
+ return {
|
|
|
+ "status": "unknown",
|
|
|
+ "reason": "production_line_context_required",
|
|
|
+ "evidence": None,
|
|
|
+ }
|
|
|
+ if str(row["asset_type"]) == "rule":
|
|
|
+ return rule_compatibility(
|
|
|
+ binding,
|
|
|
+ row.get("schema_hashes"),
|
|
|
+ row.get("logical_plan_id"),
|
|
|
+ row.get("compile_evidence_id"),
|
|
|
+ row.get("test_evidence_id"),
|
|
|
+ )
|
|
|
+ bound_rules = _array(row.get("bound_rules", []), "standard bound rules")
|
|
|
+ if not bound_rules or len(bound_rules) != int(row.get("binding_count", 0)):
|
|
|
+ return {
|
|
|
+ "status": "unknown",
|
|
|
+ "reason": "standard_trusted_rule_evidence_incomplete",
|
|
|
+ "evidence": {"bound_rules": []},
|
|
|
+ }
|
|
|
+ summaries = [
|
|
|
+ rule_compatibility(
|
|
|
+ _object(item.get("rule_spec"), "bound rule spec"),
|
|
|
+ item.get("schema_hashes"),
|
|
|
+ item.get("logical_plan_id"),
|
|
|
+ item.get("compile_evidence_id"),
|
|
|
+ item.get("test_evidence_id"),
|
|
|
+ )
|
|
|
+ for item in bound_rules
|
|
|
+ ]
|
|
|
+ statuses = {summary["status"] for summary in summaries}
|
|
|
+ status = (
|
|
|
+ "unknown"
|
|
|
+ if "unknown" in statuses
|
|
|
+ else "incompatible"
|
|
|
+ if "incompatible" in statuses
|
|
|
+ else "compatible"
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "status": status,
|
|
|
+ "reason": (
|
|
|
+ "all_standard_rule_hashes_match"
|
|
|
+ if status == "compatible"
|
|
|
+ else "standard_bound_rule_schema_drift"
|
|
|
+ ),
|
|
|
+ "evidence": {
|
|
|
+ "bound_rules": [
|
|
|
+ {
|
|
|
+ "rule_version_id": str(item["rule_version_id"]),
|
|
|
+ **summary["evidence"],
|
|
|
+ }
|
|
|
+ for item, summary in zip(bound_rules, summaries, strict=True)
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ items = []
|
|
|
+ for row in rows:
|
|
|
+ if row["version_id"] is None:
|
|
|
+ continue
|
|
|
+ items.append(
|
|
|
+ {
|
|
|
+ "asset_type": str(row["asset_type"]),
|
|
|
+ "version_id": str(row["version_id"]),
|
|
|
+ "asset_uid": str(row["asset_uid"]),
|
|
|
+ "name": str(row["name"]),
|
|
|
+ "version": int(row["version_no"]),
|
|
|
+ "owner": (
|
|
|
+ str(row["owner_uid"]) if row["owner_uid"] is not None else None
|
|
|
+ ),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "schema_compatibility": compatibility_summary(
|
|
|
+ row["schema_compatibility"],
|
|
|
+ row,
|
|
|
+ ),
|
|
|
+ "impact_count": int(row["impact_count"]),
|
|
|
+ "backend": str(row["backend"]),
|
|
|
+ "latest_evidence": {
|
|
|
+ "compile": evidence_summary(row, "compile"),
|
|
|
+ "test": evidence_summary(row, "test"),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ )
|
|
|
+ total = int(rows[0]["total_count"]) if rows else 0
|
|
|
+ return {
|
|
|
+ "items": items,
|
|
|
+ "total": total,
|
|
|
+ "limit": limit,
|
|
|
+ "offset": offset,
|
|
|
+ }
|
|
|
+
|
|
|
+ def get_published_asset(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ asset_type: str,
|
|
|
+ version_id: str,
|
|
|
+ input_schema_refs: list[str] | None = None,
|
|
|
+ output_schema_ref: str | None = None,
|
|
|
+ schema_resolver: Any | None = None,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ page = self.search_published_assets(
|
|
|
+ asset_type=asset_type,
|
|
|
+ version_id=version_id,
|
|
|
+ input_schema_refs=input_schema_refs,
|
|
|
+ output_schema_ref=output_schema_ref,
|
|
|
+ schema_resolver=schema_resolver,
|
|
|
+ limit=1,
|
|
|
+ offset=0,
|
|
|
+ )
|
|
|
+ if len(page["items"]) != 1:
|
|
|
+ raise ValueError("published asset was not found")
|
|
|
+ return page["items"][0]
|
|
|
+
|
|
|
+ def require_published_rule_version(self, rule_version_id: str) -> dict[str, Any]:
|
|
|
+ """Attest a published RuleVersion and its exact trusted evidence."""
|
|
|
+ version = _uid(rule_version_id, "rule_version_id")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.id::text AS id, rv.rule_uid::text AS rule_uid, "
|
|
|
+ "rv.version_no, rv.status "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_logical_plans lp "
|
|
|
+ "ON lp.rule_version_id = rv.id "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "AND rv.status = 'published' "
|
|
|
+ "AND lp.status = 'published' "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_compile_evidence ce "
|
|
|
+ "WHERE ce.logical_plan_id = lp.id "
|
|
|
+ "AND ce.status = 'success' "
|
|
|
+ "AND ce.compiler_version = lp.compiler_version "
|
|
|
+ "AND ce.plan_hash = lp.plan_hash "
|
|
|
+ "AND ce.schema_hashes = lp.schema_hashes "
|
|
|
+ "AND ce.capabilities = lp.capabilities) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_test_evidence te "
|
|
|
+ "WHERE te.logical_plan_id = lp.id "
|
|
|
+ "AND te.status = 'success' "
|
|
|
+ "AND te.plan_hash = lp.plan_hash "
|
|
|
+ "AND te.schema_hashes = lp.schema_hashes) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_publication_audits pa "
|
|
|
+ "WHERE pa.rule_version_id = rv.id "
|
|
|
+ "AND pa.action = 'published' "
|
|
|
+ "AND pa.to_status = 'published' "
|
|
|
+ "AND pa.evidence_hash = lp.plan_hash) "
|
|
|
+ "ORDER BY lp.created_at DESC LIMIT 1 "
|
|
|
+ "/* require_published_rule_version */"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("rule version is not published and trusted")
|
|
|
+ return {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "rule_uid": str(row["rule_uid"]),
|
|
|
+ "version_no": int(row["version_no"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ def get_asset_evidence(self, *, asset_type: str, version_id: str) -> dict[str, Any]:
|
|
|
+ """Return IDs, hashes and statuses; never return model/data payloads."""
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ if asset_type == "standard":
|
|
|
+ return self._get_standard_asset_evidence(version)
|
|
|
+ if asset_type != "rule":
|
|
|
+ raise ValueError("asset_type is invalid")
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT rv.id::text AS version_id, "
|
|
|
+ "rv.status AS version_status, "
|
|
|
+ "gr.id::text AS generation_id, "
|
|
|
+ "gr.decision AS generation_status, gr.candidate_hash, "
|
|
|
+ "gr.model_hash, gr.prompt_hash, gr.context_hash, "
|
|
|
+ "lp.id::text AS logical_plan_id, "
|
|
|
+ "lp.plan_hash AS logical_plan_hash, "
|
|
|
+ "lp.backend AS logical_backend, "
|
|
|
+ "lce.id::text AS logical_compile_id, "
|
|
|
+ "lce.status AS logical_compile_status, "
|
|
|
+ "lte.id::text AS logical_test_id, "
|
|
|
+ "lte.status AS logical_test_status, "
|
|
|
+ "lte.test_kind AS logical_test_kind, "
|
|
|
+ "lte.run_id::text AS logical_test_run_id, "
|
|
|
+ "pa.id::text AS publication_id, "
|
|
|
+ "pa.to_status AS publication_status, "
|
|
|
+ "p.id::text AS physical_plan_id, "
|
|
|
+ "p.plan_hash AS physical_plan_hash, "
|
|
|
+ "p.backend AS physical_backend, "
|
|
|
+ "pce.id::text AS physical_compile_id, "
|
|
|
+ "pce.status AS physical_compile_status, "
|
|
|
+ "pte.id::text AS physical_test_id, "
|
|
|
+ "pte.status AS physical_test_status, "
|
|
|
+ "pte.test_kind AS physical_test_kind, "
|
|
|
+ "pte.run_id::text AS physical_test_run_id "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_generation_runs value "
|
|
|
+ "WHERE value.rule_version_id = rv.id "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) gr ON TRUE "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_logical_plans value "
|
|
|
+ "WHERE value.rule_version_id = rv.id "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lp ON TRUE "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_logical_compile_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = lp.id "
|
|
|
+ "AND value.plan_hash = lp.plan_hash "
|
|
|
+ "AND value.compiler_version = lp.compiler_version "
|
|
|
+ "AND value.schema_hashes = lp.schema_hashes "
|
|
|
+ "AND value.capabilities = lp.capabilities "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lce ON TRUE "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_logical_test_evidence value "
|
|
|
+ "WHERE value.logical_plan_id = lp.id "
|
|
|
+ "AND value.plan_hash = lp.plan_hash "
|
|
|
+ "AND value.schema_hashes = lp.schema_hashes "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) lte ON TRUE "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_publication_audits value "
|
|
|
+ "WHERE value.rule_version_id = rv.id "
|
|
|
+ "AND value.action = 'published' "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) pa ON TRUE "
|
|
|
+ "LEFT JOIN public.dataflow_component_bindings cb "
|
|
|
+ "ON cb.rule_version_id = rv.id "
|
|
|
+ "LEFT JOIN public.rule_execution_plans p "
|
|
|
+ "ON p.component_binding_id = cb.id "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_compile_evidence value "
|
|
|
+ "WHERE value.rule_execution_plan_id = p.id "
|
|
|
+ "AND value.legacy_untrusted = FALSE "
|
|
|
+ "AND value.plan_hash = p.plan_hash "
|
|
|
+ "AND value.schema_hashes = p.schema_hashes "
|
|
|
+ "AND value.binding_hashes = jsonb_build_object("
|
|
|
+ "'input', p.plan->>'input_binding_hash', "
|
|
|
+ "'output', p.plan->>'output_binding_hash') "
|
|
|
+ "AND value.capabilities = COALESCE("
|
|
|
+ "p.plan->'capabilities', "
|
|
|
+ "jsonb_build_object('backend', p.backend)) "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) pce ON TRUE "
|
|
|
+ "LEFT JOIN LATERAL (SELECT value.* FROM "
|
|
|
+ "public.rule_test_evidence value "
|
|
|
+ "WHERE value.rule_execution_plan_id = p.id "
|
|
|
+ "AND value.legacy_untrusted = FALSE "
|
|
|
+ "AND value.plan_hash = p.plan_hash "
|
|
|
+ "AND value.schema_hashes = p.schema_hashes "
|
|
|
+ "AND value.binding_hashes = jsonb_build_object("
|
|
|
+ "'input', p.plan->>'input_binding_hash', "
|
|
|
+ "'output', p.plan->>'output_binding_hash') "
|
|
|
+ "ORDER BY value.created_at DESC LIMIT 1) pte ON TRUE "
|
|
|
+ "WHERE rv.id = CAST(:id AS uuid) "
|
|
|
+ "ORDER BY p.created_at DESC NULLS LAST "
|
|
|
+ "/* safe_rule_asset_evidence */"
|
|
|
+ ),
|
|
|
+ {"id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ if not rows:
|
|
|
+ raise ValueError("rule version was not found")
|
|
|
+ first = rows[0]
|
|
|
+
|
|
|
+ def optional_stage(identifier: Any, **values: Any) -> dict[str, Any] | None:
|
|
|
+ if identifier is None:
|
|
|
+ return None
|
|
|
+ return {
|
|
|
+ "id": str(identifier),
|
|
|
+ **{
|
|
|
+ key: str(value) if value is not None else None
|
|
|
+ for key, value in values.items()
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ physical = []
|
|
|
+ seen_plans: set[str] = set()
|
|
|
+ for row in rows:
|
|
|
+ if row["physical_plan_id"] is None:
|
|
|
+ continue
|
|
|
+ plan_id = str(row["physical_plan_id"])
|
|
|
+ if plan_id in seen_plans:
|
|
|
+ continue
|
|
|
+ seen_plans.add(plan_id)
|
|
|
+ physical.append(
|
|
|
+ {
|
|
|
+ "plan_id": plan_id,
|
|
|
+ "plan_hash": str(row["physical_plan_hash"]),
|
|
|
+ "backend": str(row["physical_backend"]),
|
|
|
+ "compile": optional_stage(
|
|
|
+ row["physical_compile_id"],
|
|
|
+ status=row["physical_compile_status"],
|
|
|
+ ),
|
|
|
+ "test": optional_stage(
|
|
|
+ row["physical_test_id"],
|
|
|
+ status=row["physical_test_status"],
|
|
|
+ test_kind=row["physical_test_kind"],
|
|
|
+ run_id=row["physical_test_run_id"],
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "asset_type": "rule",
|
|
|
+ "version_id": version,
|
|
|
+ "status": str(first["version_status"]),
|
|
|
+ "stages": {
|
|
|
+ "generation": optional_stage(
|
|
|
+ first["generation_id"],
|
|
|
+ status=first["generation_status"],
|
|
|
+ candidate_hash=first["candidate_hash"],
|
|
|
+ model_hash=first["model_hash"],
|
|
|
+ prompt_hash=first["prompt_hash"],
|
|
|
+ context_hash=first["context_hash"],
|
|
|
+ ),
|
|
|
+ "logical_compile": optional_stage(
|
|
|
+ first["logical_compile_id"],
|
|
|
+ status=first["logical_compile_status"],
|
|
|
+ plan_id=first["logical_plan_id"],
|
|
|
+ plan_hash=first["logical_plan_hash"],
|
|
|
+ backend=first["logical_backend"],
|
|
|
+ ),
|
|
|
+ "dry_run": optional_stage(
|
|
|
+ first["logical_test_id"],
|
|
|
+ status=first["logical_test_status"],
|
|
|
+ test_kind=first["logical_test_kind"],
|
|
|
+ run_id=first["logical_test_run_id"],
|
|
|
+ plan_hash=first["logical_plan_hash"],
|
|
|
+ ),
|
|
|
+ "publication": optional_stage(
|
|
|
+ first["publication_id"],
|
|
|
+ status=first["publication_status"],
|
|
|
+ plan_hash=first["logical_plan_hash"],
|
|
|
+ ),
|
|
|
+ "physical": physical,
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def _get_standard_asset_evidence(self, version_id: str) -> dict[str, Any]:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT sv.id::text AS version_id, "
|
|
|
+ "sv.standard_uid::text AS standard_uid, s.name, "
|
|
|
+ "sv.version_no, s.owner_uid::text AS owner_uid, "
|
|
|
+ "sv.status AS version_status, sv.published_at, "
|
|
|
+ "COALESCE(array_agg(srb.rule_version_id::text "
|
|
|
+ "ORDER BY srb.clause_id) FILTER "
|
|
|
+ "(WHERE srb.rule_version_id IS NOT NULL), "
|
|
|
+ "ARRAY[]::text[]) AS rule_version_ids "
|
|
|
+ "FROM public.data_standard_versions sv "
|
|
|
+ "JOIN public.data_standards s "
|
|
|
+ "ON s.standard_uid = sv.standard_uid "
|
|
|
+ "LEFT JOIN public.standard_rule_bindings srb "
|
|
|
+ "ON srb.standard_version_id = sv.id "
|
|
|
+ "WHERE sv.id = CAST(:id AS uuid) "
|
|
|
+ "GROUP BY sv.id, sv.standard_uid, s.name, sv.version_no, "
|
|
|
+ "s.owner_uid, sv.status, sv.published_at "
|
|
|
+ "/* safe_standard_asset_evidence */"
|
|
|
+ ),
|
|
|
+ {"id": version_id},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("standard version was not found")
|
|
|
+ published_at = row["published_at"]
|
|
|
+ if hasattr(published_at, "isoformat"):
|
|
|
+ published_at = published_at.isoformat()
|
|
|
+ return {
|
|
|
+ "asset_type": "standard",
|
|
|
+ "version_id": version_id,
|
|
|
+ "asset_uid": str(row["standard_uid"]),
|
|
|
+ "name": str(row["name"]),
|
|
|
+ "version": int(row["version_no"]),
|
|
|
+ "owner": (str(row["owner_uid"]) if row["owner_uid"] is not None else None),
|
|
|
+ "status": str(row["version_status"]),
|
|
|
+ "stages": {
|
|
|
+ "generation": None,
|
|
|
+ "logical_compile": None,
|
|
|
+ "dry_run": None,
|
|
|
+ "publication": {
|
|
|
+ "status": str(row["version_status"]),
|
|
|
+ "published_at": published_at,
|
|
|
+ },
|
|
|
+ "physical": [],
|
|
|
+ },
|
|
|
+ "bound_rule_version_ids": [str(value) for value in row["rule_version_ids"]],
|
|
|
+ }
|
|
|
+
|
|
|
+ def publish_rule_version(
|
|
|
+ self, *, version_id: str, published_by: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ return self.publish_validated_version(
|
|
|
+ version_id=version_id, actor_uid=published_by
|
|
|
+ )
|
|
|
+
|
|
|
+ def create_standard_version(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ standard_spec: dict[str, Any],
|
|
|
+ source_text: str,
|
|
|
+ created_by: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ spec = validate_standard_spec(standard_spec)
|
|
|
+ source = _text(source_text, "source_text", 20_000)
|
|
|
+ actor = _uid(created_by, "created_by")
|
|
|
+ standard_uid = spec["standard_uid"]
|
|
|
+ digest = standard_spec_hash(spec)
|
|
|
+ rule_version_ids = sorted(
|
|
|
+ {clause["rule_version_id"] for clause in spec["clauses"]}
|
|
|
+ )
|
|
|
+
|
|
|
+ published_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id "
|
|
|
+ "FROM public.data_rule_versions "
|
|
|
+ "WHERE id = ANY(CAST(:rule_version_ids AS uuid[])) "
|
|
|
+ "AND status = 'published' "
|
|
|
+ "/* published_rule_reference */"
|
|
|
+ ),
|
|
|
+ {"rule_version_ids": rule_version_ids},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ published_ids = {str(row["id"]) for row in published_rows}
|
|
|
+ if published_ids != set(rule_version_ids):
|
|
|
+ raise ValueError("standard clauses require published rule versions")
|
|
|
+
|
|
|
+ self._lock(standard_uid)
|
|
|
+ existing = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, version_no, status "
|
|
|
+ "FROM public.data_standard_versions "
|
|
|
+ "WHERE standard_uid = CAST(:standard_uid AS uuid) "
|
|
|
+ "AND spec_hash = :spec_hash "
|
|
|
+ "/* existing_version */"
|
|
|
+ ),
|
|
|
+ {"standard_uid": standard_uid, "spec_hash": digest},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return {
|
|
|
+ **dict(existing),
|
|
|
+ "standard_uid": standard_uid,
|
|
|
+ "spec_hash": digest,
|
|
|
+ "created": False,
|
|
|
+ }
|
|
|
+
|
|
|
+ version_no = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT COALESCE(MAX(version_no), 0) + 1 "
|
|
|
+ "FROM public.data_standard_versions "
|
|
|
+ "WHERE standard_uid = CAST(:standard_uid AS uuid)"
|
|
|
+ ),
|
|
|
+ {"standard_uid": standard_uid},
|
|
|
+ ).scalar_one()
|
|
|
+ version_id = new_governance_uid()
|
|
|
+ try:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.data_standards "
|
|
|
+ "(id, standard_uid, name, owner_uid, status) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:standard_uid AS uuid), "
|
|
|
+ ":name, CAST(:owner_uid AS uuid), 'active') "
|
|
|
+ "ON CONFLICT (standard_uid) DO UPDATE SET "
|
|
|
+ "name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "standard_uid": standard_uid,
|
|
|
+ "name": spec["name"],
|
|
|
+ "owner_uid": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.data_standard_versions "
|
|
|
+ "(id, standard_uid, version_no, source_text, standard_spec, "
|
|
|
+ "spec_hash, scope, status, created_by) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:standard_uid AS uuid), "
|
|
|
+ ":version_no, :source_text, CAST(:standard_spec AS jsonb), "
|
|
|
+ ":spec_hash, CAST(:scope AS jsonb), 'validated', "
|
|
|
+ "CAST(:created_by AS uuid))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": version_id,
|
|
|
+ "standard_uid": standard_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "source_text": source,
|
|
|
+ "standard_spec": _json(spec),
|
|
|
+ "spec_hash": digest,
|
|
|
+ "scope": _json(spec["scope"]),
|
|
|
+ "created_by": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ for clause in spec["clauses"]:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.standard_rule_bindings "
|
|
|
+ "(id, standard_version_id, clause_id, rule_version_id, "
|
|
|
+ "severity, exception_policy) "
|
|
|
+ "VALUES (CAST(:id AS uuid), "
|
|
|
+ "CAST(:standard_version_id AS uuid), :clause_id, "
|
|
|
+ "CAST(:rule_version_id AS uuid), :severity, "
|
|
|
+ ":exception_policy)"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "standard_version_id": version_id,
|
|
|
+ "clause_id": clause["id"],
|
|
|
+ "rule_version_id": clause["rule_version_id"],
|
|
|
+ "severity": clause["severity"],
|
|
|
+ "exception_policy": clause["exception_policy"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ except IntegrityError as exc:
|
|
|
+ raise ValueError("standard version conflicts with existing data") from exc
|
|
|
+ return {
|
|
|
+ "id": version_id,
|
|
|
+ "standard_uid": standard_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "status": "validated",
|
|
|
+ "spec_hash": digest,
|
|
|
+ "created": True,
|
|
|
+ }
|
|
|
+
|
|
|
+ def publish_standard_version(
|
|
|
+ self, *, version_id: str, published_by: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version = _uid(version_id, "version_id")
|
|
|
+ _uid(published_by, "published_by")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.data_standard_versions "
|
|
|
+ "SET status = 'published', published_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:version_id AS uuid) "
|
|
|
+ "AND status = 'validated' "
|
|
|
+ "RETURNING id::text AS id, standard_uid::text AS standard_uid, "
|
|
|
+ "version_no, status, spec_hash"
|
|
|
+ ),
|
|
|
+ {"version_id": version},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is not None:
|
|
|
+ return dict(row)
|
|
|
+ status = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT status /* current_version_status */ "
|
|
|
+ "FROM public.data_standard_versions "
|
|
|
+ "WHERE id = CAST(:version_id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"version_id": version},
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if status == "published":
|
|
|
+ raise ValueError("standard version is already published and immutable")
|
|
|
+ if status is None:
|
|
|
+ raise ValueError("standard version was not found")
|
|
|
+ raise ValueError("only validated standard versions may be published")
|
|
|
+
|
|
|
+ def load_published_assets(
|
|
|
+ self, dataflow_spec: dict[str, Any]
|
|
|
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
|
|
+ """Load exactly the published assets referenced by a DataFlow."""
|
|
|
+
|
|
|
+ flow = validate_dataflow_spec(dataflow_spec)
|
|
|
+ standard_ids = sorted(
|
|
|
+ {
|
|
|
+ component["standard_version_id"]
|
|
|
+ for component in flow["components"]
|
|
|
+ if component["type"] == "standard.enforce"
|
|
|
+ }
|
|
|
+ )
|
|
|
+ standard_rows = []
|
|
|
+ if standard_ids:
|
|
|
+ standard_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT sv.id::text AS id, sv.status, "
|
|
|
+ "jsonb_agg(jsonb_build_object("
|
|
|
+ "'clause_id', b.clause_id, "
|
|
|
+ "'rule_version_id', b.rule_version_id::text, "
|
|
|
+ "'severity', b.severity, "
|
|
|
+ "'exception_policy', b.exception_policy) "
|
|
|
+ "ORDER BY b.clause_id) AS clauses "
|
|
|
+ "FROM public.data_standard_versions sv "
|
|
|
+ "JOIN public.standard_rule_bindings b "
|
|
|
+ "ON b.standard_version_id = sv.id "
|
|
|
+ "WHERE sv.id = ANY(CAST(:standard_ids AS uuid[])) "
|
|
|
+ "AND sv.status = 'published' "
|
|
|
+ "/* published_standard_assets */ "
|
|
|
+ "GROUP BY sv.id, sv.status"
|
|
|
+ ),
|
|
|
+ {"standard_ids": standard_ids},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ standards = {
|
|
|
+ str(row["id"]): {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "clauses": _array(row["clauses"], "standard clauses"),
|
|
|
+ }
|
|
|
+ for row in standard_rows
|
|
|
+ }
|
|
|
+ if set(standards) != set(standard_ids):
|
|
|
+ raise ValueError(
|
|
|
+ "dataflow references missing or unpublished standard versions"
|
|
|
+ )
|
|
|
+
|
|
|
+ rule_ids = {
|
|
|
+ component["rule_version_id"]
|
|
|
+ for component in flow["components"]
|
|
|
+ if component["type"] != "standard.enforce"
|
|
|
+ }
|
|
|
+ for standard in standards.values():
|
|
|
+ rule_ids.update(
|
|
|
+ str(clause["rule_version_id"]) for clause in standard["clauses"]
|
|
|
+ )
|
|
|
+ rule_rows = []
|
|
|
+ if rule_ids:
|
|
|
+ rule_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT DISTINCT ON (rv.id) rv.id::text AS id, "
|
|
|
+ "rv.status, rv.rule_spec, rv.spec_hash "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.rule_logical_plans lp "
|
|
|
+ "ON lp.rule_version_id = rv.id "
|
|
|
+ "JOIN public.rule_logical_compile_evidence lce "
|
|
|
+ "ON lce.logical_plan_id = lp.id "
|
|
|
+ "JOIN public.rule_logical_test_evidence lte "
|
|
|
+ "ON lte.logical_plan_id = lp.id "
|
|
|
+ "WHERE rv.id = ANY(CAST(:rule_ids AS uuid[])) "
|
|
|
+ "AND rv.status = 'published' "
|
|
|
+ "AND lp.status = 'published' "
|
|
|
+ "AND lp.plan->>'rule_version_id' = rv.id::text "
|
|
|
+ "AND lp.plan->>'rule_spec_hash' = rv.spec_hash "
|
|
|
+ "AND lce.status = 'success' "
|
|
|
+ "AND lce.compiler_version = lp.compiler_version "
|
|
|
+ "AND lce.plan_hash = lp.plan_hash "
|
|
|
+ "AND lce.schema_hashes = lp.schema_hashes "
|
|
|
+ "AND lce.capabilities = lp.capabilities "
|
|
|
+ "AND lte.status = 'success' "
|
|
|
+ "AND lte.plan_hash = lp.plan_hash "
|
|
|
+ "AND lte.schema_hashes = lp.schema_hashes "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_publication_audits pa "
|
|
|
+ "WHERE pa.rule_version_id = rv.id "
|
|
|
+ "AND pa.rule_execution_plan_id IS NULL "
|
|
|
+ "AND pa.action = 'published' "
|
|
|
+ "AND pa.to_status = 'published' "
|
|
|
+ "AND pa.evidence_hash = lp.plan_hash) "
|
|
|
+ "/* published_rule_assets */ "
|
|
|
+ "ORDER BY rv.id, lp.created_at DESC"
|
|
|
+ ),
|
|
|
+ {"rule_ids": sorted(rule_ids)},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ rules = {
|
|
|
+ str(row["id"]): {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "rule_spec": _object(row["rule_spec"], "rule spec"),
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ }
|
|
|
+ for row in rule_rows
|
|
|
+ }
|
|
|
+ if set(rules) != rule_ids:
|
|
|
+ raise ValueError("dataflow references missing or unpublished rule versions")
|
|
|
+ return standards, rules
|
|
|
+
|
|
|
+ def begin_dataflow_release(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ dataflow_spec: dict[str, Any],
|
|
|
+ source_text: str,
|
|
|
+ input_schema_hashes: dict[str, str],
|
|
|
+ output_schema_hash: str,
|
|
|
+ created_by: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ flow = validate_dataflow_spec(dataflow_spec)
|
|
|
+ source = _text(source_text, "source_text", 20_000)
|
|
|
+ actor = _uid(created_by, "created_by")
|
|
|
+ if not isinstance(input_schema_hashes, dict):
|
|
|
+ raise ValueError("input_schema_hashes must be an object")
|
|
|
+ inputs = {
|
|
|
+ str(key): _digest(value, f"schema hash for {key}")
|
|
|
+ for key, value in sorted(input_schema_hashes.items())
|
|
|
+ }
|
|
|
+ if set(inputs) != set(flow["input_schema_refs"]):
|
|
|
+ raise ValueError("input schema hashes do not match dataflow inputs")
|
|
|
+ output = _digest(output_schema_hash, "output_schema_hash")
|
|
|
+ dataflow_uid = flow["dataflow_uid"]
|
|
|
+ self._lock(dataflow_uid)
|
|
|
+ version_no = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT COALESCE(MAX(version_no), 0) + 1 "
|
|
|
+ "FROM public.dataflow_versions "
|
|
|
+ "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid)"
|
|
|
+ ),
|
|
|
+ {"dataflow_uid": dataflow_uid},
|
|
|
+ ).scalar_one()
|
|
|
+ version_id = new_governance_uid()
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_versions "
|
|
|
+ "(id, dataflow_uid, version_no, name, source_text, "
|
|
|
+ "dataflow_spec, input_schema_hashes, output_schema_hash, "
|
|
|
+ "status, created_by) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
|
|
|
+ ":version_no, :name, :source_text, "
|
|
|
+ "CAST(:dataflow_spec AS jsonb), "
|
|
|
+ "CAST(:input_schema_hashes AS jsonb), :output_schema_hash, "
|
|
|
+ "'validated', CAST(:created_by AS uuid))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": version_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "name": flow["name"],
|
|
|
+ "source_text": source,
|
|
|
+ "dataflow_spec": _json(flow),
|
|
|
+ "input_schema_hashes": _json(inputs),
|
|
|
+ "output_schema_hash": output,
|
|
|
+ "created_by": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "id": version_id,
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
+ "version_no": version_no,
|
|
|
+ "status": "validated",
|
|
|
+ "dataflow_spec_hash": dataflow_spec_hash(flow),
|
|
|
+ }
|
|
|
+
|
|
|
+ def find_schema_snapshot(
|
|
|
+ self, *, schema_ref: str, schema_hash: str
|
|
|
+ ) -> dict[str, Any] | None:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, schema_ref, schema_hash, fields, "
|
|
|
+ "source_revision FROM public.data_schema_snapshots "
|
|
|
+ "WHERE schema_ref = :schema_ref AND schema_hash = :schema_hash"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "schema_ref": _text(schema_ref, "schema_ref", 500),
|
|
|
+ "schema_hash": _digest(schema_hash, "schema_hash"),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ return None
|
|
|
+ snapshot = validate_schema_snapshot(
|
|
|
+ {
|
|
|
+ "schema_ref": str(row["schema_ref"]),
|
|
|
+ "schema_hash": str(row["schema_hash"]),
|
|
|
+ "fields": _array(row["fields"], "schema snapshot fields"),
|
|
|
+ "source_revision": str(row["source_revision"]),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {"id": str(row["id"]), **snapshot}
|
|
|
+
|
|
|
+ def persist_schema_snapshot(self, *, snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ normalized = validate_schema_snapshot(snapshot)
|
|
|
+ existing = self.find_schema_snapshot(
|
|
|
+ schema_ref=normalized["schema_ref"],
|
|
|
+ schema_hash=normalized["schema_hash"],
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return existing
|
|
|
+ snapshot_id = new_governance_uid()
|
|
|
+ inserted = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.data_schema_snapshots "
|
|
|
+ "(id, schema_ref, schema_hash, fields, source_revision) "
|
|
|
+ "VALUES (CAST(:id AS uuid), :schema_ref, :schema_hash, "
|
|
|
+ "CAST(:fields AS jsonb), :source_revision) "
|
|
|
+ "ON CONFLICT (schema_ref, schema_hash) DO NOTHING "
|
|
|
+ "RETURNING id::text AS id"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": snapshot_id,
|
|
|
+ "schema_ref": normalized["schema_ref"],
|
|
|
+ "schema_hash": normalized["schema_hash"],
|
|
|
+ "fields": _json(normalized["fields"]),
|
|
|
+ "source_revision": normalized["source_revision"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if inserted is None:
|
|
|
+ existing = self.find_schema_snapshot(
|
|
|
+ schema_ref=normalized["schema_ref"],
|
|
|
+ schema_hash=normalized["schema_hash"],
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return existing
|
|
|
+ raise ValueError("schema snapshot could not be persisted")
|
|
|
+ return {"id": str(inserted["id"]), **normalized}
|
|
|
+
|
|
|
+ def persist_dataset_binding(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ deployment_id: str,
|
|
|
+ logical_ref: str,
|
|
|
+ binding: dict[str, Any],
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ _uid(actor_uid, "actor_uid")
|
|
|
+ logical = _text(logical_ref, "logical_ref", 500)
|
|
|
+ normalized = validate_dataset_binding(binding)
|
|
|
+ binding_hash = _canonical_hash({"logical_ref": logical, **normalized})
|
|
|
+ binding_id = new_governance_uid()
|
|
|
+ try:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_dataset_bindings "
|
|
|
+ "(id, dataflow_deployment_id, logical_ref, data_source_uid, "
|
|
|
+ "object_kind, object_ref, schema_snapshot_id, dialect, "
|
|
|
+ "access_mode, write_mode, binding_hash) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
|
|
|
+ ":logical_ref, CAST(:data_source_uid AS uuid), :object_kind, "
|
|
|
+ ":object_ref, CAST(:schema_snapshot_id AS uuid), :dialect, "
|
|
|
+ ":access_mode, :write_mode, :binding_hash) "
|
|
|
+ "RETURNING id::text AS id"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": binding_id,
|
|
|
+ "deployment_id": deployment,
|
|
|
+ "logical_ref": logical,
|
|
|
+ **normalized,
|
|
|
+ "binding_hash": binding_hash,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one()
|
|
|
+ )
|
|
|
+ except IntegrityError as exc:
|
|
|
+ raise ValueError(
|
|
|
+ "dataset binding already exists for deployment logical_ref"
|
|
|
+ ) from exc
|
|
|
+ return {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "logical_ref": logical,
|
|
|
+ **normalized,
|
|
|
+ "binding_hash": binding_hash,
|
|
|
+ }
|
|
|
+
|
|
|
+ def persist_component_plan(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ dataflow_version_id: str,
|
|
|
+ component_binding_id: str,
|
|
|
+ component_id: str,
|
|
|
+ component_kind: str,
|
|
|
+ rule_version_id: str,
|
|
|
+ stage: str,
|
|
|
+ order_no: int,
|
|
|
+ idempotency: dict[str, Any] | None,
|
|
|
+ provenance: dict[str, Any],
|
|
|
+ plan: dict[str, Any],
|
|
|
+ schema_hashes: dict[str, Any],
|
|
|
+ ) -> None:
|
|
|
+ version_id = _uid(dataflow_version_id, "dataflow_version_id")
|
|
|
+ binding_id = _uid(component_binding_id, "component_binding_id")
|
|
|
+ rule_id = _uid(rule_version_id, "rule_version_id")
|
|
|
+ component = _text(component_id, "component_id", 100)
|
|
|
+ if component_kind not in COMPONENT_KINDS:
|
|
|
+ raise ValueError("unsupported component kind")
|
|
|
+ if not isinstance(order_no, int) or isinstance(order_no, bool) or order_no < 0:
|
|
|
+ raise ValueError("order_no must be a non-negative integer")
|
|
|
+ if not isinstance(provenance, dict):
|
|
|
+ raise ValueError("provenance must be an object")
|
|
|
+ if not isinstance(plan, dict):
|
|
|
+ raise ValueError("compiled plan must be an object")
|
|
|
+ if set(plan) != {
|
|
|
+ "backend",
|
|
|
+ "compiler_version",
|
|
|
+ "plan",
|
|
|
+ "plan_hash",
|
|
|
+ }:
|
|
|
+ raise ValueError("compiled plan contains unsupported fields")
|
|
|
+ if plan["backend"] not in PLAN_BACKENDS:
|
|
|
+ raise ValueError("unsupported plan backend")
|
|
|
+ compiler_version = _text(plan["compiler_version"], "compiler_version", 80)
|
|
|
+ plan_body = _object(plan["plan"], "compiled plan body")
|
|
|
+ plan_hash = _digest(plan["plan_hash"], "plan_hash")
|
|
|
+ if _canonical_hash(plan_body) != plan_hash:
|
|
|
+ raise ValueError("compiled plan hash does not match")
|
|
|
+ if not isinstance(schema_hashes, dict):
|
|
|
+ raise ValueError("schema_hashes must be an object")
|
|
|
+ if component_kind == "rule.apply" and not isinstance(idempotency, dict):
|
|
|
+ raise ValueError("rule.apply requires idempotency")
|
|
|
+ if component_kind == "quality.check" and idempotency is not None:
|
|
|
+ raise ValueError("quality.check cannot define idempotency")
|
|
|
+
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_component_bindings "
|
|
|
+ "(id, dataflow_version_id, component_id, component_kind, "
|
|
|
+ "standard_version_id, rule_version_id, stage, order_no, "
|
|
|
+ "idempotency, provenance) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:dataflow_version_id AS uuid), "
|
|
|
+ ":component_id, :component_kind, NULL, "
|
|
|
+ "CAST(:rule_version_id AS uuid), :stage, :order_no, "
|
|
|
+ "CAST(:idempotency AS jsonb), CAST(:provenance AS jsonb))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": binding_id,
|
|
|
+ "dataflow_version_id": version_id,
|
|
|
+ "component_id": component,
|
|
|
+ "component_kind": component_kind,
|
|
|
+ "rule_version_id": rule_id,
|
|
|
+ "stage": _text(stage, "stage", 30),
|
|
|
+ "order_no": order_no,
|
|
|
+ "idempotency": _json(idempotency) if idempotency is not None else None,
|
|
|
+ "provenance": _json(provenance),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_execution_plans "
|
|
|
+ "(id, component_binding_id, backend, compiler_version, plan, "
|
|
|
+ "plan_hash, schema_hashes, status) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:binding_id AS uuid), "
|
|
|
+ ":backend, :compiler_version, CAST(:plan AS jsonb), "
|
|
|
+ ":plan_hash, CAST(:schema_hashes AS jsonb), 'compiled')"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "binding_id": binding_id,
|
|
|
+ "backend": plan["backend"],
|
|
|
+ "compiler_version": compiler_version,
|
|
|
+ "plan": _json(plan_body),
|
|
|
+ "plan_hash": plan_hash,
|
|
|
+ "schema_hashes": _json(schema_hashes),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ def load_bound_compile_context(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ component_binding_id: str,
|
|
|
+ rule_version_id: str,
|
|
|
+ input_schema_snapshot_id: str,
|
|
|
+ output_schema_snapshot_id: str,
|
|
|
+ input_binding_id: str,
|
|
|
+ output_binding_id: str,
|
|
|
+ ) -> dict[str, Any] | None:
|
|
|
+ """Load all physical compilation inputs through one canonical join."""
|
|
|
+
|
|
|
+ ids = {
|
|
|
+ "component_binding_id": _uid(component_binding_id, "component_binding_id"),
|
|
|
+ "rule_version_id": _uid(rule_version_id, "rule_version_id"),
|
|
|
+ "input_schema_snapshot_id": _uid(
|
|
|
+ input_schema_snapshot_id, "input_schema_snapshot_id"
|
|
|
+ ),
|
|
|
+ "output_schema_snapshot_id": _uid(
|
|
|
+ output_schema_snapshot_id, "output_schema_snapshot_id"
|
|
|
+ ),
|
|
|
+ "input_binding_id": _uid(input_binding_id, "input_binding_id"),
|
|
|
+ "output_binding_id": _uid(output_binding_id, "output_binding_id"),
|
|
|
+ }
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT cb.id::text AS component_binding_id, "
|
|
|
+ "cb.rule_version_id::text AS component_rule_version_id, "
|
|
|
+ "cb.component_kind, cb.idempotency, "
|
|
|
+ "rv.id::text AS rule_version_id, rv.rule_spec, "
|
|
|
+ "rv.spec_hash, rv.status AS rule_status, "
|
|
|
+ "ins.id::text AS input_schema_snapshot_id, "
|
|
|
+ "ins.schema_ref AS input_schema_ref, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "ins.fields AS input_schema_fields, "
|
|
|
+ "ins.source_revision AS input_source_revision, "
|
|
|
+ "outs.id::text AS output_schema_snapshot_id, "
|
|
|
+ "outs.schema_ref AS output_schema_ref, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "outs.fields AS output_schema_fields, "
|
|
|
+ "outs.source_revision AS output_source_revision, "
|
|
|
+ "ib.id::text AS input_binding_id, "
|
|
|
+ "ib.data_source_uid::text AS input_data_source_uid, "
|
|
|
+ "ib.object_kind AS input_object_kind, "
|
|
|
+ "ib.object_ref AS input_object_ref, "
|
|
|
+ "ib.access_mode AS input_access_mode, "
|
|
|
+ "ib.dialect AS input_dialect, "
|
|
|
+ "ib.write_mode AS input_write_mode, "
|
|
|
+ "ib.binding_hash AS input_binding_hash, "
|
|
|
+ "ob.id::text AS output_binding_id, "
|
|
|
+ "ob.data_source_uid::text AS output_data_source_uid, "
|
|
|
+ "ob.object_kind AS output_object_kind, "
|
|
|
+ "ob.object_ref AS output_object_ref, "
|
|
|
+ "ob.access_mode AS output_access_mode, "
|
|
|
+ "ob.dialect AS output_dialect, "
|
|
|
+ "ob.write_mode AS output_write_mode, "
|
|
|
+ "ob.binding_hash AS output_binding_hash "
|
|
|
+ "FROM public.dataflow_component_bindings cb "
|
|
|
+ "JOIN public.data_rule_versions rv "
|
|
|
+ "ON rv.id = cb.rule_version_id "
|
|
|
+ "JOIN public.dataflow_deployments d "
|
|
|
+ "ON d.dataflow_version_id = cb.dataflow_version_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ib "
|
|
|
+ "ON ib.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = ib.schema_snapshot_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ob "
|
|
|
+ "ON ob.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = ob.schema_snapshot_id "
|
|
|
+ "WHERE cb.id = CAST(:component_binding_id AS uuid) "
|
|
|
+ "AND rv.id = CAST(:rule_version_id AS uuid) "
|
|
|
+ "AND ins.id = CAST(:input_schema_snapshot_id AS uuid) "
|
|
|
+ "AND outs.id = CAST(:output_schema_snapshot_id AS uuid) "
|
|
|
+ "AND ib.id = CAST(:input_binding_id AS uuid) "
|
|
|
+ "AND ob.id = CAST(:output_binding_id AS uuid) "
|
|
|
+ "/* canonical_bound_compile_context */"
|
|
|
+ ),
|
|
|
+ ids,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ return None
|
|
|
+
|
|
|
+ def binding(prefix: str, snapshot_id: str) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "id": str(row[f"{prefix}_binding_id"]),
|
|
|
+ "data_source_uid": str(row[f"{prefix}_data_source_uid"]),
|
|
|
+ "object_kind": str(row[f"{prefix}_object_kind"]),
|
|
|
+ "object_ref": str(row[f"{prefix}_object_ref"]),
|
|
|
+ "schema_snapshot_id": snapshot_id,
|
|
|
+ "access_mode": str(row[f"{prefix}_access_mode"]),
|
|
|
+ "dialect": str(row[f"{prefix}_dialect"]),
|
|
|
+ "write_mode": row[f"{prefix}_write_mode"],
|
|
|
+ "binding_hash": str(row[f"{prefix}_binding_hash"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ input_snapshot_id = str(row["input_schema_snapshot_id"])
|
|
|
+ output_snapshot_id = str(row["output_schema_snapshot_id"])
|
|
|
+ rule_spec = _object(row["rule_spec"], "rule_spec")
|
|
|
+ artifact_batch = (
|
|
|
+ str(row["input_object_kind"]) == "parquet_artifact"
|
|
|
+ and str(row["output_object_kind"]) == "parquet_artifact"
|
|
|
+ )
|
|
|
+ if artifact_batch:
|
|
|
+ backend = {
|
|
|
+ "max_rows": 100_000,
|
|
|
+ "max_artifact_bytes": 32 * 1024 * 1024,
|
|
|
+ "memory_limit_bytes": 256 * 1024 * 1024,
|
|
|
+ "masking_policies": {
|
|
|
+ "customer_mobile_last4": "preserve_last_4",
|
|
|
+ "redact": "redact",
|
|
|
+ },
|
|
|
+ "lookup_bindings": {},
|
|
|
+ }
|
|
|
+ for step in rule_spec.get("steps", []):
|
|
|
+ if step.get("op") != "lookup_join":
|
|
|
+ continue
|
|
|
+ lookup = step.get("lookup")
|
|
|
+ if not isinstance(lookup, dict):
|
|
|
+ raise ValueError("canonical lookup rule is invalid")
|
|
|
+ lookup_id = _uid(lookup.get("binding_id"), "lookup binding id")
|
|
|
+ lookup_row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT lb.id::text AS binding_id, "
|
|
|
+ "lb.data_source_uid::text AS data_source_uid, "
|
|
|
+ "lb.object_kind, lb.object_ref, "
|
|
|
+ "lb.schema_snapshot_id::text AS snapshot_id, "
|
|
|
+ "lb.access_mode, lb.dialect, lb.write_mode, "
|
|
|
+ "lb.binding_hash, ss.schema_ref, ss.schema_hash, "
|
|
|
+ "ss.fields, ss.source_revision "
|
|
|
+ "FROM public.dataflow_dataset_bindings lb "
|
|
|
+ "JOIN public.data_schema_snapshots ss "
|
|
|
+ "ON ss.id = lb.schema_snapshot_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ib "
|
|
|
+ "ON ib.dataflow_deployment_id = "
|
|
|
+ "lb.dataflow_deployment_id "
|
|
|
+ "WHERE lb.id = CAST(:lookup_binding_id AS uuid) "
|
|
|
+ "AND ib.id = CAST(:input_binding_id AS uuid) "
|
|
|
+ "AND lb.object_kind = 'parquet_artifact' "
|
|
|
+ "AND lb.access_mode IN ('read', 'read_write') "
|
|
|
+ "/* canonical_polars_lookup_context */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "lookup_binding_id": lookup_id,
|
|
|
+ "input_binding_id": ids["input_binding_id"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if lookup_row is None:
|
|
|
+ raise ValueError("canonical Polars lookup binding was not found")
|
|
|
+ snapshot_id = str(lookup_row["snapshot_id"])
|
|
|
+ backend["lookup_bindings"][lookup_id] = {
|
|
|
+ "binding": {
|
|
|
+ "id": str(lookup_row["binding_id"]),
|
|
|
+ "data_source_uid": str(lookup_row["data_source_uid"]),
|
|
|
+ "object_kind": str(lookup_row["object_kind"]),
|
|
|
+ "object_ref": str(lookup_row["object_ref"]),
|
|
|
+ "schema_snapshot_id": snapshot_id,
|
|
|
+ "access_mode": str(lookup_row["access_mode"]),
|
|
|
+ "dialect": str(lookup_row["dialect"]),
|
|
|
+ "write_mode": lookup_row["write_mode"],
|
|
|
+ "binding_hash": str(lookup_row["binding_hash"]),
|
|
|
+ },
|
|
|
+ "schema": {
|
|
|
+ "id": snapshot_id,
|
|
|
+ "schema_ref": str(lookup_row["schema_ref"]),
|
|
|
+ "schema_hash": str(lookup_row["schema_hash"]),
|
|
|
+ "fields": _array(
|
|
|
+ lookup_row["fields"],
|
|
|
+ "lookup schema fields",
|
|
|
+ ),
|
|
|
+ "source_revision": str(lookup_row["source_revision"]),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ else:
|
|
|
+ dialect = str(row["input_dialect"]).strip().lower()
|
|
|
+ dialect = "postgresql" if dialect == "postgres" else dialect
|
|
|
+ if dialect not in {"postgresql", "mysql"}:
|
|
|
+ raise ValueError("canonical SQL binding dialect is unsupported")
|
|
|
+ backend = {
|
|
|
+ "dialect": dialect,
|
|
|
+ "timezone": str(rule_spec.get("timezone") or ""),
|
|
|
+ "collation": ("C" if dialect == "postgresql" else "utf8mb4_0900_bin"),
|
|
|
+ "rounding_mode": "half_away_from_zero",
|
|
|
+ "regex_engine": ("posix" if dialect == "postgresql" else "icu"),
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "component_binding": {
|
|
|
+ "id": str(row["component_binding_id"]),
|
|
|
+ "rule_version_id": str(row["component_rule_version_id"]),
|
|
|
+ "component_kind": str(row["component_kind"]),
|
|
|
+ "idempotency": row["idempotency"],
|
|
|
+ },
|
|
|
+ "rule_version": {
|
|
|
+ "id": str(row["rule_version_id"]),
|
|
|
+ "rule_spec": rule_spec,
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ "status": str(row["rule_status"]),
|
|
|
+ },
|
|
|
+ "input_schema": {
|
|
|
+ "id": input_snapshot_id,
|
|
|
+ "schema_ref": str(row["input_schema_ref"]),
|
|
|
+ "schema_hash": str(row["input_schema_hash"]),
|
|
|
+ "fields": _array(row["input_schema_fields"], "input schema fields"),
|
|
|
+ "source_revision": str(row["input_source_revision"]),
|
|
|
+ },
|
|
|
+ "output_schema": {
|
|
|
+ "id": output_snapshot_id,
|
|
|
+ "schema_ref": str(row["output_schema_ref"]),
|
|
|
+ "schema_hash": str(row["output_schema_hash"]),
|
|
|
+ "fields": _array(row["output_schema_fields"], "output schema fields"),
|
|
|
+ "source_revision": str(row["output_source_revision"]),
|
|
|
+ },
|
|
|
+ "input_binding": binding("input", input_snapshot_id),
|
|
|
+ "output_binding": binding("output", output_snapshot_id),
|
|
|
+ "backend": backend,
|
|
|
+ }
|
|
|
+
|
|
|
+ def persist_bound_component_plan(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ component_binding_id: str,
|
|
|
+ rule_version_id: str,
|
|
|
+ input_binding_id: str,
|
|
|
+ output_binding_id: str,
|
|
|
+ compiled: dict[str, Any],
|
|
|
+ status: str = "compiled",
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ """Persist a deployment-bound SQL or Polars plan without publishing it.
|
|
|
+
|
|
|
+ The physical bindings must belong to one deployment whose immutable
|
|
|
+ DataFlowVersion owns the logical component binding. Publication and
|
|
|
+ test evidence are intentionally separate lifecycle transitions.
|
|
|
+ """
|
|
|
+
|
|
|
+ component_id = _uid(component_binding_id, "component_binding_id")
|
|
|
+ rule_id = _uid(rule_version_id, "rule_version_id")
|
|
|
+ input_id = _uid(input_binding_id, "input_binding_id")
|
|
|
+ output_id = _uid(output_binding_id, "output_binding_id")
|
|
|
+ if status != "compiled":
|
|
|
+ raise ValueError("bound plans may only be persisted as compiled")
|
|
|
+ if not isinstance(compiled, dict) or set(compiled) != {
|
|
|
+ "backend",
|
|
|
+ "compiler_version",
|
|
|
+ "status",
|
|
|
+ "plan",
|
|
|
+ "plan_hash",
|
|
|
+ }:
|
|
|
+ raise ValueError("compiled bound plan has an invalid shape")
|
|
|
+ backend_name = str(compiled["backend"])
|
|
|
+ if (
|
|
|
+ backend_name not in {"sql_pushdown", "polars_batch"}
|
|
|
+ or compiled["status"] != status
|
|
|
+ ):
|
|
|
+ raise ValueError("compiled bound plan status is invalid")
|
|
|
+ compiler_version = _text(compiled["compiler_version"], "compiler_version", 80)
|
|
|
+ if backend_name == "sql_pushdown":
|
|
|
+ from app.core.data_rules.compilers.sql import (
|
|
|
+ bound_sql_plan_hash,
|
|
|
+ validate_bound_sql_plan,
|
|
|
+ )
|
|
|
+
|
|
|
+ plan = validate_bound_sql_plan(compiled["plan"])
|
|
|
+ expected_plan_hash = bound_sql_plan_hash(plan)
|
|
|
+ else:
|
|
|
+ from app.core.data_rules.compilers.polars import (
|
|
|
+ bound_polars_plan_hash,
|
|
|
+ validate_bound_polars_plan,
|
|
|
+ )
|
|
|
+
|
|
|
+ plan = validate_bound_polars_plan(compiled["plan"])
|
|
|
+ expected_plan_hash = bound_polars_plan_hash(plan)
|
|
|
+ plan_hash = _digest(compiled["plan_hash"], "plan_hash")
|
|
|
+ if expected_plan_hash != plan_hash:
|
|
|
+ raise ValueError("compiled bound plan hash does not match")
|
|
|
+ if (
|
|
|
+ plan["rule_version_id"] != rule_id
|
|
|
+ or plan["input_binding_id"] != input_id
|
|
|
+ or plan["output_binding_id"] != output_id
|
|
|
+ or plan["compiler_version"] != compiler_version
|
|
|
+ ):
|
|
|
+ raise ValueError("compiled bound plan identifiers do not match")
|
|
|
+
|
|
|
+ linkage = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT cb.id::text AS component_binding_id, "
|
|
|
+ "rv.spec_hash AS rule_spec_hash, "
|
|
|
+ "ins.id::text AS input_schema_snapshot_id, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "outs.id::text AS output_schema_snapshot_id, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "ib.object_ref AS input_object_ref, "
|
|
|
+ "ob.object_ref AS output_object_ref, "
|
|
|
+ "ib.object_kind AS input_object_kind, "
|
|
|
+ "ob.object_kind AS output_object_kind, "
|
|
|
+ "ib.binding_hash AS input_binding_hash, "
|
|
|
+ "ob.binding_hash AS output_binding_hash, "
|
|
|
+ "ib.data_source_uid::text AS data_source_uid, "
|
|
|
+ "ib.dialect AS input_dialect, "
|
|
|
+ "ob.dialect AS output_dialect "
|
|
|
+ "FROM public.dataflow_component_bindings cb "
|
|
|
+ "JOIN public.data_rule_versions rv "
|
|
|
+ "ON rv.id = cb.rule_version_id "
|
|
|
+ "JOIN public.dataflow_deployments d "
|
|
|
+ "ON d.dataflow_version_id = cb.dataflow_version_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ib "
|
|
|
+ "ON ib.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = ib.schema_snapshot_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ob "
|
|
|
+ "ON ob.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = ob.schema_snapshot_id "
|
|
|
+ "WHERE cb.id = CAST(:component_binding_id AS uuid) "
|
|
|
+ "AND cb.rule_version_id = CAST(:rule_version_id AS uuid) "
|
|
|
+ "AND ib.id = CAST(:input_binding_id AS uuid) "
|
|
|
+ "AND ob.id = CAST(:output_binding_id AS uuid) "
|
|
|
+ "/* bound_plan_linkage */"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "component_binding_id": component_id,
|
|
|
+ "rule_version_id": rule_id,
|
|
|
+ "input_binding_id": input_id,
|
|
|
+ "output_binding_id": output_id,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if linkage is None:
|
|
|
+ raise ValueError(
|
|
|
+ "bound plan bindings do not share the component deployment"
|
|
|
+ )
|
|
|
+ common_mismatch = (
|
|
|
+ str(linkage["rule_spec_hash"]) != plan["rule_spec_hash"]
|
|
|
+ or str(linkage["input_schema_snapshot_id"])
|
|
|
+ != plan["input_schema_snapshot_id"]
|
|
|
+ or str(linkage["input_schema_hash"]) != plan["input_schema_hash"]
|
|
|
+ or str(linkage["output_schema_snapshot_id"])
|
|
|
+ != plan["output_schema_snapshot_id"]
|
|
|
+ or str(linkage["output_schema_hash"]) != plan["output_schema_hash"]
|
|
|
+ )
|
|
|
+ if backend_name == "sql_pushdown":
|
|
|
+ from app.core.data_rules.compilers.sql import (
|
|
|
+ bound_sql_plan_relations,
|
|
|
+ )
|
|
|
+
|
|
|
+ relations = bound_sql_plan_relations(plan)
|
|
|
+ mismatch = (
|
|
|
+ common_mismatch
|
|
|
+ or str(linkage["data_source_uid"]) != plan["data_source_uid"]
|
|
|
+ or str(linkage["input_object_ref"]) != relations["input_object_ref"]
|
|
|
+ or str(linkage["output_object_ref"]) != relations["output_object_ref"]
|
|
|
+ or str(linkage["input_dialect"]) != plan["dialect"]
|
|
|
+ or str(linkage["output_dialect"]) != plan["dialect"]
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ mismatch = (
|
|
|
+ common_mismatch
|
|
|
+ or str(linkage["input_object_kind"]) != "parquet_artifact"
|
|
|
+ or str(linkage["output_object_kind"]) != "parquet_artifact"
|
|
|
+ or str(linkage["input_binding_hash"]) != plan["input_binding_hash"]
|
|
|
+ or str(linkage["output_binding_hash"]) != plan["output_binding_hash"]
|
|
|
+ )
|
|
|
+ if mismatch:
|
|
|
+ raise ValueError("bound plan does not match its physical dataset bindings")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_execution_plans "
|
|
|
+ "(id, component_binding_id, backend, compiler_version, plan, "
|
|
|
+ "plan_hash, schema_hashes, status) "
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:component_binding_id AS uuid), "
|
|
|
+ ":backend, :compiler_version, CAST(:plan AS jsonb), "
|
|
|
+ ":plan_hash, CAST(:schema_hashes AS jsonb), :status) "
|
|
|
+ "RETURNING id::text AS id, status"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "component_binding_id": component_id,
|
|
|
+ "backend": backend_name,
|
|
|
+ "compiler_version": compiler_version,
|
|
|
+ "plan": _json(plan),
|
|
|
+ "plan_hash": plan_hash,
|
|
|
+ "schema_hashes": _json(
|
|
|
+ {
|
|
|
+ "rule_spec_hash": plan["rule_spec_hash"],
|
|
|
+ "input_schema_snapshot_id": plan[
|
|
|
+ "input_schema_snapshot_id"
|
|
|
+ ],
|
|
|
+ "input_schema_hash": plan["input_schema_hash"],
|
|
|
+ "output_schema_snapshot_id": plan[
|
|
|
+ "output_schema_snapshot_id"
|
|
|
+ ],
|
|
|
+ "output_schema_hash": plan["output_schema_hash"],
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ "status": status,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one()
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "backend": backend_name,
|
|
|
+ "plan_hash": plan_hash,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _load_physical_gate_context(
|
|
|
+ self, *, plan_id: str, require_compile_evidence: bool
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ plan = _uid(plan_id, "plan_id")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT p.id::text AS plan_id, p.status AS plan_status, "
|
|
|
+ "p.backend, p.compiler_version, p.plan, p.plan_hash, "
|
|
|
+ "p.schema_hashes, p.component_binding_id::text, "
|
|
|
+ "rv.id::text AS version_id, rv.status AS version_status, "
|
|
|
+ "rv.rule_spec, rv.spec_hash, "
|
|
|
+ "ib.id::text AS input_binding_id, "
|
|
|
+ "ob.id::text AS output_binding_id, "
|
|
|
+ "ib.binding_hash AS input_binding_hash, "
|
|
|
+ "ob.binding_hash AS output_binding_hash, "
|
|
|
+ "ib.data_source_uid::text AS input_data_source_uid, "
|
|
|
+ "ob.data_source_uid::text AS output_data_source_uid, "
|
|
|
+ "ib.object_kind AS input_object_kind, "
|
|
|
+ "ob.object_kind AS output_object_kind, "
|
|
|
+ "ib.object_ref AS input_object_ref, "
|
|
|
+ "ob.object_ref AS output_object_ref, "
|
|
|
+ "ib.dialect AS input_dialect, "
|
|
|
+ "ob.dialect AS output_dialect, "
|
|
|
+ "ins.id::text AS input_snapshot_id, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "ins.fields AS input_fields, "
|
|
|
+ "outs.id::text AS output_snapshot_id, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "outs.fields AS output_fields, "
|
|
|
+ "sample.artifact_ref AS sample_artifact_ref, "
|
|
|
+ "sample.artifact_digest AS sample_artifact_digest, "
|
|
|
+ "sample.schema_fields AS sample_schema_fields "
|
|
|
+ "FROM public.rule_execution_plans p "
|
|
|
+ "JOIN public.dataflow_component_bindings cb "
|
|
|
+ "ON cb.id = p.component_binding_id "
|
|
|
+ "JOIN public.data_rule_versions rv "
|
|
|
+ "ON rv.id = cb.rule_version_id "
|
|
|
+ "JOIN public.dataflow_deployments d "
|
|
|
+ "ON d.dataflow_version_id = cb.dataflow_version_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ib "
|
|
|
+ "ON ib.id = CAST(p.plan->>'input_binding_id' AS uuid) "
|
|
|
+ "AND ib.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = ib.schema_snapshot_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ob "
|
|
|
+ "ON ob.id = CAST(p.plan->>'output_binding_id' AS uuid) "
|
|
|
+ "AND ob.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = ob.schema_snapshot_id "
|
|
|
+ "LEFT JOIN LATERAL (SELECT a.artifact_ref, "
|
|
|
+ "a.artifact_digest, a.schema_fields "
|
|
|
+ "FROM public.rule_run_artifacts a "
|
|
|
+ "WHERE a.binding_id = ib.id "
|
|
|
+ "AND a.handoff_status = 'ready' "
|
|
|
+ "AND a.expires_at > CURRENT_TIMESTAMP "
|
|
|
+ "ORDER BY a.created_at DESC LIMIT 1) sample ON TRUE "
|
|
|
+ "WHERE p.id = CAST(:id AS uuid) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM public.rule_logical_plans lp "
|
|
|
+ "JOIN public.rule_logical_compile_evidence lce "
|
|
|
+ "ON lce.logical_plan_id = lp.id "
|
|
|
+ "JOIN public.rule_logical_test_evidence lte "
|
|
|
+ "ON lte.logical_plan_id = lp.id "
|
|
|
+ "WHERE lp.rule_version_id = rv.id "
|
|
|
+ "AND lp.status = 'published' "
|
|
|
+ "AND lce.status = 'success' "
|
|
|
+ "AND lce.plan_hash = lp.plan_hash "
|
|
|
+ "AND lte.status = 'success' "
|
|
|
+ "AND lte.plan_hash = lp.plan_hash) "
|
|
|
+ "/* physical_gate_context */ "
|
|
|
+ "FOR UPDATE OF p, cb, rv, ib, ob, ins, outs"
|
|
|
+ ),
|
|
|
+ {"id": plan},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("trusted physical plan evidence was not found")
|
|
|
+ body = _object(row["plan"], "physical plan")
|
|
|
+ if row["backend"] == "sql_pushdown":
|
|
|
+ from app.core.data_rules.compilers.sql import (
|
|
|
+ bound_sql_plan_hash,
|
|
|
+ validate_bound_sql_plan,
|
|
|
+ )
|
|
|
+
|
|
|
+ normalized = validate_bound_sql_plan(body)
|
|
|
+ expected_hash = bound_sql_plan_hash(normalized)
|
|
|
+ elif row["backend"] == "polars_batch":
|
|
|
+ from app.core.data_rules.compilers.polars import (
|
|
|
+ bound_polars_plan_hash,
|
|
|
+ validate_bound_polars_plan,
|
|
|
+ )
|
|
|
+
|
|
|
+ normalized = validate_bound_polars_plan(body)
|
|
|
+ expected_hash = bound_polars_plan_hash(normalized)
|
|
|
+ else:
|
|
|
+ raise ValueError("physical plan backend is unsupported")
|
|
|
+ schema_hashes = _object(row["schema_hashes"], "schema hashes")
|
|
|
+ rule_spec = validate_rule_spec(_object(row["rule_spec"], "rule_spec"))
|
|
|
+ expected_schema_hashes = {
|
|
|
+ "rule_spec_hash": str(row["spec_hash"]),
|
|
|
+ "input_schema_snapshot_id": str(row["input_snapshot_id"]),
|
|
|
+ "input_schema_hash": str(row["input_schema_hash"]),
|
|
|
+ "output_schema_snapshot_id": str(row["output_snapshot_id"]),
|
|
|
+ "output_schema_hash": str(row["output_schema_hash"]),
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ expected_hash != str(row["plan_hash"])
|
|
|
+ or str(row["version_status"]) != "published"
|
|
|
+ or rule_spec_hash(rule_spec) != str(row["spec_hash"])
|
|
|
+ or str(row["spec_hash"]) != normalized["rule_spec_hash"]
|
|
|
+ or str(row["compiler_version"]) != normalized["compiler_version"]
|
|
|
+ or normalized["rule_version_id"] != str(row["version_id"])
|
|
|
+ or normalized["input_binding_id"] != str(row["input_binding_id"])
|
|
|
+ or normalized["output_binding_id"] != str(row["output_binding_id"])
|
|
|
+ or normalized["input_schema_snapshot_id"] != str(row["input_snapshot_id"])
|
|
|
+ or normalized["input_schema_hash"] != str(row["input_schema_hash"])
|
|
|
+ or normalized["output_schema_snapshot_id"] != str(row["output_snapshot_id"])
|
|
|
+ or normalized["output_schema_hash"] != str(row["output_schema_hash"])
|
|
|
+ or schema_hashes != expected_schema_hashes
|
|
|
+ ):
|
|
|
+ raise ValueError("physical plan canonical attestation has drifted")
|
|
|
+ binding_hashes = {
|
|
|
+ "input": str(row["input_binding_hash"]),
|
|
|
+ "output": str(row["output_binding_hash"]),
|
|
|
+ }
|
|
|
+ if row["backend"] == "sql_pushdown":
|
|
|
+ from app.core.data_rules.compilers.sql import (
|
|
|
+ bound_sql_plan_relations,
|
|
|
+ )
|
|
|
+
|
|
|
+ relations = bound_sql_plan_relations(normalized)
|
|
|
+ input_dialect = str(row["input_dialect"]).lower()
|
|
|
+ output_dialect = str(row["output_dialect"]).lower()
|
|
|
+ input_dialect = (
|
|
|
+ "postgresql" if input_dialect == "postgres" else input_dialect
|
|
|
+ )
|
|
|
+ output_dialect = (
|
|
|
+ "postgresql" if output_dialect == "postgres" else output_dialect
|
|
|
+ )
|
|
|
+ binding_mismatch = (
|
|
|
+ str(row["input_data_source_uid"]) != str(row["output_data_source_uid"])
|
|
|
+ or normalized["data_source_uid"] != str(row["input_data_source_uid"])
|
|
|
+ or normalized["dialect"] != input_dialect
|
|
|
+ or normalized["dialect"] != output_dialect
|
|
|
+ or relations["input_object_ref"] != str(row["input_object_ref"])
|
|
|
+ or relations["output_object_ref"] != str(row["output_object_ref"])
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ binding_mismatch = (
|
|
|
+ str(row["input_object_kind"]) != "parquet_artifact"
|
|
|
+ or str(row["output_object_kind"]) != "parquet_artifact"
|
|
|
+ or normalized["input_fields"]
|
|
|
+ != _array(row["input_fields"], "input fields")
|
|
|
+ or normalized["output_fields"]
|
|
|
+ != _array(row["output_fields"], "output fields")
|
|
|
+ or normalized["input_binding_hash"] != binding_hashes["input"]
|
|
|
+ or normalized["output_binding_hash"] != binding_hashes["output"]
|
|
|
+ )
|
|
|
+ if binding_mismatch:
|
|
|
+ raise ValueError("physical plan dataset bindings have drifted")
|
|
|
+ context = {
|
|
|
+ "version_id": str(row["version_id"]),
|
|
|
+ "version_status": str(row["version_status"]),
|
|
|
+ "plan_id": str(row["plan_id"]),
|
|
|
+ "plan_status": str(row["plan_status"]),
|
|
|
+ "backend": str(row["backend"]),
|
|
|
+ "compiler_version": str(row["compiler_version"]),
|
|
|
+ "plan": normalized,
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ "schema_hashes": schema_hashes,
|
|
|
+ "binding_hashes": binding_hashes,
|
|
|
+ "capabilities": (
|
|
|
+ normalized.get("capabilities")
|
|
|
+ or {
|
|
|
+ "resource_limits": normalized.get("resource_limits"),
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ "sample_artifact": (
|
|
|
+ {
|
|
|
+ "artifact_ref": str(row["sample_artifact_ref"]),
|
|
|
+ "digest": str(row["sample_artifact_digest"]),
|
|
|
+ "schema_fields": _array(
|
|
|
+ row["sample_schema_fields"], "sample schema fields"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ if row["sample_artifact_ref"] is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ if require_compile_evidence:
|
|
|
+ trusted = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_compile_evidence ce "
|
|
|
+ "WHERE ce.rule_execution_plan_id = CAST(:id AS uuid) "
|
|
|
+ "AND ce.status = 'success' "
|
|
|
+ "AND ce.legacy_untrusted = FALSE "
|
|
|
+ "AND ce.compiler_version = :compiler_version "
|
|
|
+ "AND ce.plan_hash = :plan_hash "
|
|
|
+ "AND ce.schema_hashes = CAST(:schema_hashes AS jsonb) "
|
|
|
+ "AND ce.binding_hashes = CAST(:binding_hashes AS jsonb) "
|
|
|
+ "AND ce.capabilities = CAST(:capabilities AS jsonb))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": context["plan_id"],
|
|
|
+ "compiler_version": context["compiler_version"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "schema_hashes": _json(context["schema_hashes"]),
|
|
|
+ "binding_hashes": _json(context["binding_hashes"]),
|
|
|
+ "capabilities": _json(context["capabilities"]),
|
|
|
+ },
|
|
|
+ ).scalar_one()
|
|
|
+ if not trusted:
|
|
|
+ raise ValueError("physical compile evidence has drifted")
|
|
|
+ return context
|
|
|
+
|
|
|
+ def attest_physical_compile(
|
|
|
+ self, *, plan_id: str, actor_uid: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ context = self._load_physical_gate_context(
|
|
|
+ plan_id=plan_id, require_compile_evidence=False
|
|
|
+ )
|
|
|
+ if context["plan_status"] != "compiled":
|
|
|
+ raise ValueError("physical plan is not in compiled state")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ compiler_digest = _canonical_hash(
|
|
|
+ {
|
|
|
+ "compiler_version": context["compiler_version"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "capabilities": context["capabilities"],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ evidence_id = new_governance_uid()
|
|
|
+ evidence = {
|
|
|
+ "backend": context["backend"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ }
|
|
|
+ evidence_values = {
|
|
|
+ "id": evidence_id,
|
|
|
+ "plan_id": context["plan_id"],
|
|
|
+ "compiler_version": context["compiler_version"],
|
|
|
+ "compiler_digest": compiler_digest,
|
|
|
+ "evidence": _json(evidence),
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "schema_hashes": _json(context["schema_hashes"]),
|
|
|
+ "binding_hashes": _json(context["binding_hashes"]),
|
|
|
+ "capabilities": _json(context["capabilities"]),
|
|
|
+ "actor_uid": actor,
|
|
|
+ }
|
|
|
+ canonical_evidence_id = self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_compile_evidence "
|
|
|
+ "(id, rule_execution_plan_id, compiler_version, "
|
|
|
+ "compiler_digest, status, evidence, plan_hash, schema_hashes, "
|
|
|
+ "binding_hashes, capabilities, legacy_untrusted, created_by) "
|
|
|
+ "VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:plan_id AS uuid), "
|
|
|
+ ":compiler_version, :compiler_digest, 'success', "
|
|
|
+ "CAST(:evidence AS jsonb), :plan_hash, "
|
|
|
+ "CAST(:schema_hashes AS jsonb), "
|
|
|
+ "CAST(:binding_hashes AS jsonb), "
|
|
|
+ "CAST(:capabilities AS jsonb), FALSE, "
|
|
|
+ "CAST(:actor_uid AS uuid)) "
|
|
|
+ "ON CONFLICT (rule_execution_plan_id, compiler_digest) "
|
|
|
+ "DO NOTHING RETURNING id::text"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if canonical_evidence_id is None:
|
|
|
+ existing_evidence = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, compiler_version, "
|
|
|
+ "compiler_digest, status, evidence, plan_hash, "
|
|
|
+ "schema_hashes, binding_hashes, capabilities, "
|
|
|
+ "legacy_untrusted, created_by::text AS created_by "
|
|
|
+ "FROM public.rule_compile_evidence "
|
|
|
+ "WHERE rule_execution_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND compiler_digest = :compiler_digest"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ existing_evidence is None
|
|
|
+ or str(existing_evidence["compiler_version"])
|
|
|
+ != context["compiler_version"]
|
|
|
+ or str(existing_evidence["compiler_digest"]) != compiler_digest
|
|
|
+ or str(existing_evidence["status"]) != "success"
|
|
|
+ or _object(existing_evidence["evidence"], "compile evidence")
|
|
|
+ != evidence
|
|
|
+ or str(existing_evidence["plan_hash"]) != context["plan_hash"]
|
|
|
+ or _object(existing_evidence["schema_hashes"], "schema hashes")
|
|
|
+ != context["schema_hashes"]
|
|
|
+ or _object(existing_evidence["binding_hashes"], "binding hashes")
|
|
|
+ != context["binding_hashes"]
|
|
|
+ or _object(existing_evidence["capabilities"], "capabilities")
|
|
|
+ != context["capabilities"]
|
|
|
+ or bool(existing_evidence["legacy_untrusted"])
|
|
|
+ or str(existing_evidence["created_by"]) != actor
|
|
|
+ ):
|
|
|
+ raise ValueError("physical compile evidence replay has drifted")
|
|
|
+ canonical_evidence_id = str(existing_evidence["id"])
|
|
|
+ evidence_id = str(canonical_evidence_id)
|
|
|
+ return {
|
|
|
+ "plan_id": context["plan_id"],
|
|
|
+ "plan_status": "compiled",
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "compile_evidence_id": evidence_id,
|
|
|
+ }
|
|
|
+
|
|
|
+ def load_physical_test_context(self, *, plan_id: str) -> dict[str, Any]:
|
|
|
+ context = self._load_physical_gate_context(
|
|
|
+ plan_id=plan_id, require_compile_evidence=True
|
|
|
+ )
|
|
|
+ if context["plan_status"] in {"tested", "published"}:
|
|
|
+ terminal = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id FROM public.rule_test_evidence "
|
|
|
+ "WHERE rule_execution_plan_id = CAST(:id AS uuid) "
|
|
|
+ "AND status = 'success' "
|
|
|
+ "AND legacy_untrusted = FALSE "
|
|
|
+ "AND plan_hash = :plan_hash "
|
|
|
+ "ORDER BY created_at DESC LIMIT 1"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": context["plan_id"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if terminal is None:
|
|
|
+ raise ValueError("physical plan terminal evidence is missing")
|
|
|
+ return {
|
|
|
+ "terminal_result": {
|
|
|
+ "plan_id": context["plan_id"],
|
|
|
+ "plan_status": context["plan_status"],
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "test_evidence_id": str(terminal["id"]),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if context["plan_status"] != "compiled":
|
|
|
+ raise ValueError("physical plan is not ready for testing")
|
|
|
+ return context
|
|
|
+
|
|
|
+ def persist_physical_test(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ plan_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ context: dict[str, Any],
|
|
|
+ evidence: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ plan = _uid(plan_id, "plan_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ evidence_hash = _canonical_hash(evidence)
|
|
|
+ evidence_id = new_governance_uid()
|
|
|
+ evidence_values = {
|
|
|
+ "id": evidence_id,
|
|
|
+ "plan_id": plan,
|
|
|
+ "test_kind": evidence["test_kind"],
|
|
|
+ "evidence_hash": evidence_hash,
|
|
|
+ "evidence": _json(evidence),
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "schema_hashes": _json(context["schema_hashes"]),
|
|
|
+ "binding_hashes": _json(context["binding_hashes"]),
|
|
|
+ "run_id": evidence["run_id"],
|
|
|
+ "actor_uid": actor,
|
|
|
+ }
|
|
|
+ canonical_evidence_id = self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.rule_test_evidence "
|
|
|
+ "(id, rule_execution_plan_id, test_kind, evidence_hash, "
|
|
|
+ "status, evidence, plan_hash, schema_hashes, binding_hashes, "
|
|
|
+ "run_id, legacy_untrusted, created_by) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:plan_id AS uuid), :test_kind, "
|
|
|
+ ":evidence_hash, 'success', CAST(:evidence AS jsonb), "
|
|
|
+ ":plan_hash, CAST(:schema_hashes AS jsonb), "
|
|
|
+ "CAST(:binding_hashes AS jsonb), CAST(:run_id AS uuid), "
|
|
|
+ "FALSE, CAST(:actor_uid AS uuid)) "
|
|
|
+ "ON CONFLICT (rule_execution_plan_id, test_kind, "
|
|
|
+ "evidence_hash) DO NOTHING RETURNING id::text"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if canonical_evidence_id is None:
|
|
|
+ existing_evidence = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, test_kind, evidence_hash, "
|
|
|
+ "status, evidence, plan_hash, schema_hashes, "
|
|
|
+ "binding_hashes, run_id::text AS run_id, "
|
|
|
+ "legacy_untrusted, created_by::text AS created_by "
|
|
|
+ "FROM public.rule_test_evidence "
|
|
|
+ "WHERE rule_execution_plan_id = CAST(:plan_id AS uuid) "
|
|
|
+ "AND test_kind = :test_kind "
|
|
|
+ "AND evidence_hash = :evidence_hash"
|
|
|
+ ),
|
|
|
+ evidence_values,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ existing_evidence is None
|
|
|
+ or str(existing_evidence["test_kind"]) != evidence["test_kind"]
|
|
|
+ or str(existing_evidence["evidence_hash"]) != evidence_hash
|
|
|
+ or str(existing_evidence["status"]) != "success"
|
|
|
+ or _object(existing_evidence["evidence"], "physical test evidence")
|
|
|
+ != evidence
|
|
|
+ or str(existing_evidence["plan_hash"]) != context["plan_hash"]
|
|
|
+ or _object(existing_evidence["schema_hashes"], "schema hashes")
|
|
|
+ != context["schema_hashes"]
|
|
|
+ or _object(existing_evidence["binding_hashes"], "binding hashes")
|
|
|
+ != context["binding_hashes"]
|
|
|
+ or str(existing_evidence["run_id"]) != evidence["run_id"]
|
|
|
+ or bool(existing_evidence["legacy_untrusted"])
|
|
|
+ or str(existing_evidence["created_by"]) != actor
|
|
|
+ ):
|
|
|
+ raise ValueError("physical test evidence replay has drifted")
|
|
|
+ canonical_evidence_id = str(existing_evidence["id"])
|
|
|
+ evidence_id = str(canonical_evidence_id)
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.rule_execution_plans SET status = 'tested' "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'compiled' "
|
|
|
+ "AND plan_hash = :plan_hash"
|
|
|
+ ),
|
|
|
+ {"id": plan, "plan_hash": context["plan_hash"]},
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("physical test state changed concurrently")
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=context["version_id"],
|
|
|
+ plan_id=plan,
|
|
|
+ actor_uid=actor,
|
|
|
+ action="test_succeeded",
|
|
|
+ from_status="compiled",
|
|
|
+ to_status="tested",
|
|
|
+ evidence_hash=evidence_hash,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": "tested",
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "test_evidence_id": evidence_id,
|
|
|
+ }
|
|
|
+
|
|
|
+ def publish_tested_physical_plan(
|
|
|
+ self, *, plan_id: str, actor_uid: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ context = self._load_physical_gate_context(
|
|
|
+ plan_id=plan_id, require_compile_evidence=True
|
|
|
+ )
|
|
|
+ plan = context["plan_id"]
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ if context["plan_status"] == "published":
|
|
|
+ publication_actor = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT actor_uid::text FROM "
|
|
|
+ "public.rule_publication_audits "
|
|
|
+ "WHERE rule_execution_plan_id = CAST(:id AS uuid) "
|
|
|
+ "AND action = 'published' AND to_status = 'published' "
|
|
|
+ "ORDER BY created_at DESC LIMIT 1"
|
|
|
+ ),
|
|
|
+ {"id": plan},
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if str(publication_actor or "") == actor:
|
|
|
+ return {
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": "published",
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ }
|
|
|
+ raise ValueError("physical plan is already published and immutable")
|
|
|
+ if context["plan_status"] != "tested":
|
|
|
+ raise ValueError("physical compile and test evidence is required")
|
|
|
+ trusted = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT EXISTS (SELECT 1 FROM public.rule_test_evidence "
|
|
|
+ "WHERE rule_execution_plan_id = CAST(:id AS uuid) "
|
|
|
+ "AND status = 'success' "
|
|
|
+ "AND legacy_untrusted = FALSE "
|
|
|
+ "AND plan_hash = :plan_hash "
|
|
|
+ "AND schema_hashes = CAST(:schema_hashes AS jsonb) "
|
|
|
+ "AND binding_hashes = CAST(:binding_hashes AS jsonb))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": plan,
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ "schema_hashes": _json(context["schema_hashes"]),
|
|
|
+ "binding_hashes": _json(context["binding_hashes"]),
|
|
|
+ },
|
|
|
+ ).scalar_one()
|
|
|
+ if not trusted:
|
|
|
+ raise ValueError("physical test evidence has drifted")
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.rule_execution_plans SET status = 'published' "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'tested'"
|
|
|
+ ),
|
|
|
+ {"id": plan},
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("physical publication state changed concurrently")
|
|
|
+ self._audit_publication(
|
|
|
+ version_id=context["version_id"],
|
|
|
+ plan_id=plan,
|
|
|
+ actor_uid=actor,
|
|
|
+ action="published",
|
|
|
+ from_status="tested",
|
|
|
+ to_status="published",
|
|
|
+ evidence_hash=context["plan_hash"],
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "plan_id": plan,
|
|
|
+ "plan_status": "published",
|
|
|
+ "plan_hash": context["plan_hash"],
|
|
|
+ }
|
|
|
+
|
|
|
+ def complete_dataflow_release(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ dataflow_version_id: str,
|
|
|
+ package: dict[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version_id = _uid(dataflow_version_id, "dataflow_version_id")
|
|
|
+ if not isinstance(package, dict):
|
|
|
+ raise ValueError("production-line package must be an object")
|
|
|
+ package_hash = _digest(package.get("package_hash"), "package_hash")
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_versions "
|
|
|
+ "SET package = CAST(:package AS jsonb), "
|
|
|
+ "package_hash = :package_hash, status = 'released', "
|
|
|
+ "released_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:version_id AS uuid) "
|
|
|
+ "AND status = 'validated' "
|
|
|
+ "RETURNING id::text AS id, version_no, status, package_hash"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "version_id": version_id,
|
|
|
+ "package": _json(package),
|
|
|
+ "package_hash": package_hash,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("dataflow release is not in validated state")
|
|
|
+ return {**dict(row), "package": package}
|
|
|
+
|
|
|
+ def load_release_catalogs(
|
|
|
+ self, dataflow_spec: dict[str, Any]
|
|
|
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
|
|
+ flow = validate_dataflow_spec(dataflow_spec)
|
|
|
+ standard_ids = sorted(
|
|
|
+ {
|
|
|
+ component["standard_version_id"]
|
|
|
+ for component in flow["components"]
|
|
|
+ if component["type"] == "standard.enforce"
|
|
|
+ }
|
|
|
+ )
|
|
|
+ direct_rule_ids = {
|
|
|
+ component["rule_version_id"]
|
|
|
+ for component in flow["components"]
|
|
|
+ if component["type"] != "standard.enforce"
|
|
|
+ }
|
|
|
+
|
|
|
+ standard_rows = []
|
|
|
+ if standard_ids:
|
|
|
+ standard_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT sv.id::text AS id, sv.status, "
|
|
|
+ "jsonb_agg(jsonb_build_object("
|
|
|
+ "'clause_id', b.clause_id, "
|
|
|
+ "'rule_version_id', b.rule_version_id::text, "
|
|
|
+ "'severity', b.severity, "
|
|
|
+ "'exception_policy', b.exception_policy) "
|
|
|
+ "ORDER BY b.clause_id) AS clauses "
|
|
|
+ "FROM public.data_standard_versions sv "
|
|
|
+ "JOIN public.standard_rule_bindings b "
|
|
|
+ "ON b.standard_version_id = sv.id "
|
|
|
+ "WHERE sv.id = ANY(CAST(:standard_ids AS uuid[])) "
|
|
|
+ "AND sv.status = 'published' "
|
|
|
+ "/* catalog_standard_versions */ "
|
|
|
+ "GROUP BY sv.id, sv.status"
|
|
|
+ ),
|
|
|
+ {"standard_ids": standard_ids},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ standards = {
|
|
|
+ str(row["id"]): {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "clauses": _array(row["clauses"], "standard clauses"),
|
|
|
+ }
|
|
|
+ for row in standard_rows
|
|
|
+ }
|
|
|
+ if set(standards) != set(standard_ids):
|
|
|
+ raise ValueError(
|
|
|
+ "dataflow references missing or unpublished standard versions"
|
|
|
+ )
|
|
|
+
|
|
|
+ rule_ids = set(direct_rule_ids)
|
|
|
+ for standard in standards.values():
|
|
|
+ rule_ids.update(
|
|
|
+ str(clause["rule_version_id"]) for clause in standard["clauses"]
|
|
|
+ )
|
|
|
+ rule_rows = []
|
|
|
+ if rule_ids:
|
|
|
+ rule_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT DISTINCT ON (rv.id) "
|
|
|
+ "rv.id::text AS id, rv.status, rv.rule_spec, rv.spec_hash, "
|
|
|
+ "p.backend, p.plan_hash "
|
|
|
+ "FROM public.data_rule_versions rv "
|
|
|
+ "JOIN public.dataflow_component_bindings cb "
|
|
|
+ "ON cb.rule_version_id = rv.id "
|
|
|
+ "JOIN public.rule_execution_plans p "
|
|
|
+ "ON p.component_binding_id = cb.id "
|
|
|
+ "WHERE rv.id = ANY(CAST(:rule_ids AS uuid[])) "
|
|
|
+ "AND rv.status = 'published' "
|
|
|
+ "AND p.status = 'published' "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_publication_audits pa "
|
|
|
+ "WHERE pa.rule_execution_plan_id = p.id "
|
|
|
+ "AND pa.rule_version_id = rv.id "
|
|
|
+ "AND pa.action = 'published' "
|
|
|
+ "AND pa.to_status = 'published' "
|
|
|
+ "AND pa.evidence_hash = p.plan_hash) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_logical_plans lp "
|
|
|
+ "JOIN public.rule_logical_compile_evidence lce "
|
|
|
+ "ON lce.logical_plan_id = lp.id "
|
|
|
+ "JOIN public.rule_logical_test_evidence lte "
|
|
|
+ "ON lte.logical_plan_id = lp.id "
|
|
|
+ "WHERE lp.rule_version_id = rv.id "
|
|
|
+ "AND lp.status = 'published' "
|
|
|
+ "AND lce.status = 'success' "
|
|
|
+ "AND lce.plan_hash = lp.plan_hash "
|
|
|
+ "AND lte.status = 'success' "
|
|
|
+ "AND lte.plan_hash = lp.plan_hash) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM "
|
|
|
+ "public.rule_compile_evidence pce "
|
|
|
+ "JOIN public.rule_test_evidence pte "
|
|
|
+ "ON pte.rule_execution_plan_id = "
|
|
|
+ "pce.rule_execution_plan_id "
|
|
|
+ "WHERE pce.rule_execution_plan_id = p.id "
|
|
|
+ "AND pce.status = 'success' "
|
|
|
+ "AND pce.legacy_untrusted = FALSE "
|
|
|
+ "AND pce.plan_hash = p.plan_hash "
|
|
|
+ "AND pte.status = 'success' "
|
|
|
+ "AND pte.legacy_untrusted = FALSE "
|
|
|
+ "AND pte.plan_hash = p.plan_hash) "
|
|
|
+ "/* catalog_rule_versions */ "
|
|
|
+ "ORDER BY rv.id, p.created_at DESC"
|
|
|
+ ),
|
|
|
+ {"rule_ids": sorted(rule_ids)},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ rules = {
|
|
|
+ str(row["id"]): {
|
|
|
+ "id": str(row["id"]),
|
|
|
+ "status": str(row["status"]),
|
|
|
+ "rule_spec": _object(row["rule_spec"], "rule spec"),
|
|
|
+ "spec_hash": str(row["spec_hash"]),
|
|
|
+ "execution_plan": {
|
|
|
+ "backend": str(row["backend"]),
|
|
|
+ "plan_hash": str(row["plan_hash"]),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for row in rule_rows
|
|
|
+ }
|
|
|
+ if set(rules) != rule_ids:
|
|
|
+ raise ValueError(
|
|
|
+ "dataflow references rules without published execution plans"
|
|
|
+ )
|
|
|
+ return standards, rules
|
|
|
+
|
|
|
+ # ------------------------------------------------------------------
|
|
|
+ # Governed Data Factory deployment lifecycle (M5)
|
|
|
+ # ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _deployment_result(row) -> dict[str, Any]:
|
|
|
+ result = dict(row)
|
|
|
+ for key in (
|
|
|
+ "id",
|
|
|
+ "dataflow_version_id",
|
|
|
+ "dataflow_uid",
|
|
|
+ "workflow_version_id",
|
|
|
+ "schedule_plan_id",
|
|
|
+ "canary_evidence_id",
|
|
|
+ "previous_active_deployment_id",
|
|
|
+ "created_by",
|
|
|
+ "activated_by",
|
|
|
+ "correlation_id",
|
|
|
+ ):
|
|
|
+ if result.get(key) is not None:
|
|
|
+ result[key] = str(result[key])
|
|
|
+ for key, label, default in (
|
|
|
+ ("package", "deployment package", {}),
|
|
|
+ ("standard_version_ids", "standard version ids", []),
|
|
|
+ ("rule_version_ids", "rule version ids", []),
|
|
|
+ ("binding_snapshot", "binding snapshot", {}),
|
|
|
+ ("schema_snapshots", "schema snapshots", {}),
|
|
|
+ ("physical_plan_hashes", "physical plan hashes", []),
|
|
|
+ ("workflow_spec", "workflow spec", {}),
|
|
|
+ ("schedule_snapshot", "schedule snapshot", {}),
|
|
|
+ ("deploy_receipt", "deploy receipt", {}),
|
|
|
+ ):
|
|
|
+ value = result.get(key)
|
|
|
+ if value is None:
|
|
|
+ result[key] = copy.deepcopy(default)
|
|
|
+ elif isinstance(default, dict):
|
|
|
+ result[key] = _object(value, label)
|
|
|
+ else:
|
|
|
+ result[key] = _array(value, label)
|
|
|
+ for key in (
|
|
|
+ "created_at",
|
|
|
+ "updated_at",
|
|
|
+ "activated_at",
|
|
|
+ "superseded_at",
|
|
|
+ "rolled_back_at",
|
|
|
+ ):
|
|
|
+ value = result.get(key)
|
|
|
+ if value is not None and hasattr(value, "isoformat"):
|
|
|
+ result[key] = value.isoformat()
|
|
|
+ return result
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _deployment_select() -> str:
|
|
|
+ return (
|
|
|
+ "SELECT d.id::text AS id, "
|
|
|
+ "d.dataflow_version_id::text AS dataflow_version_id, "
|
|
|
+ "v.version_no, d.dataflow_uid::text AS dataflow_uid, "
|
|
|
+ "d.environment, d.workflow_version_id::text AS workflow_version_id, "
|
|
|
+ "d.schedule_plan_id::text AS schedule_plan_id, "
|
|
|
+ "d.deployment_config, d.status, d.package, d.package_hash, "
|
|
|
+ "d.standard_version_ids, d.rule_version_ids, "
|
|
|
+ "d.binding_snapshot, d.binding_hash, d.schema_snapshots, "
|
|
|
+ "d.schema_snapshot_hash, d.physical_plan_hashes, "
|
|
|
+ "d.workflow_spec, d.workflow_spec_hash, "
|
|
|
+ "d.schedule_snapshot, d.schedule_hash, "
|
|
|
+ "d.engine_namespace, d.engine_definition_id, "
|
|
|
+ "d.engine_revision, d.engine_definition_hash, "
|
|
|
+ "d.engine_definition_hash_version, d.deploy_receipt, "
|
|
|
+ "d.canary_evidence_id::text AS canary_evidence_id, "
|
|
|
+ "d.previous_active_deployment_id::text "
|
|
|
+ "AS previous_active_deployment_id, "
|
|
|
+ "d.created_by::text AS created_by, d.create_reason, "
|
|
|
+ "d.create_idempotency_key, d.correlation_id::text AS correlation_id, "
|
|
|
+ "d.lock_version, d.failure_code, "
|
|
|
+ "d.activated_by::text AS activated_by, d.activated_at, "
|
|
|
+ "d.superseded_at, d.rolled_back_at, d.created_at, d.updated_at "
|
|
|
+ "FROM public.dataflow_deployments d "
|
|
|
+ "JOIN public.dataflow_versions v ON v.id = d.dataflow_version_id "
|
|
|
+ )
|
|
|
+
|
|
|
+ def load_deployable_release(
|
|
|
+ self, dataflow_version_id: str, *, environment: str | None = None
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ version_id = _uid(dataflow_version_id, "dataflow_version_id")
|
|
|
+ if environment not in {"development", "test", "production"}:
|
|
|
+ raise ValueError("deployment environment is invalid")
|
|
|
+ version = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, dataflow_uid::text AS dataflow_uid, "
|
|
|
+ "version_no, status, package, package_hash "
|
|
|
+ "FROM public.dataflow_versions "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'released'"
|
|
|
+ ),
|
|
|
+ {"id": version_id},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if version is None:
|
|
|
+ raise ValueError("released dataflow version was not found")
|
|
|
+ package = _object(version["package"], "production-line package")
|
|
|
+ package_hash = _digest(version["package_hash"], "package_hash")
|
|
|
+ if package.get("package_hash") != package_hash:
|
|
|
+ raise ValueError("production-line package has drifted")
|
|
|
+
|
|
|
+ # Task 7 creates a server-owned preparatory deployment so its physical
|
|
|
+ # plans can be compiled and tested against exact bindings. M5 promotes
|
|
|
+ # that same identity instead of copying plan identifiers into another
|
|
|
+ # deployment where Runner linkage would no longer attest.
|
|
|
+ context_rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT d.id::text AS deployment_id, "
|
|
|
+ "cb.id::text AS component_binding_id, "
|
|
|
+ "p.id::text AS plan_id, p.plan_hash, "
|
|
|
+ "ib.id::text AS input_binding_id, "
|
|
|
+ "ib.logical_ref AS input_logical_ref, "
|
|
|
+ "ib.data_source_uid::text AS input_data_source_uid, "
|
|
|
+ "ib.object_kind AS input_object_kind, "
|
|
|
+ "ib.object_ref AS input_object_ref, "
|
|
|
+ "ib.access_mode AS input_access_mode, "
|
|
|
+ "ib.write_mode AS input_write_mode, "
|
|
|
+ "ib.dialect AS input_dialect, "
|
|
|
+ "ib.binding_hash AS input_binding_hash, "
|
|
|
+ "ob.id::text AS output_binding_id, "
|
|
|
+ "ob.logical_ref AS output_logical_ref, "
|
|
|
+ "ob.data_source_uid::text AS output_data_source_uid, "
|
|
|
+ "ob.object_kind AS output_object_kind, "
|
|
|
+ "ob.object_ref AS output_object_ref, "
|
|
|
+ "ob.access_mode AS output_access_mode, "
|
|
|
+ "ob.write_mode AS output_write_mode, "
|
|
|
+ "ob.dialect AS output_dialect, "
|
|
|
+ "ob.binding_hash AS output_binding_hash, "
|
|
|
+ "ins.id::text AS input_snapshot_id, "
|
|
|
+ "ins.schema_hash AS input_schema_hash, "
|
|
|
+ "ins.schema_ref AS input_schema_ref, "
|
|
|
+ "ins.fields AS input_fields, "
|
|
|
+ "outs.id::text AS output_snapshot_id, "
|
|
|
+ "outs.schema_hash AS output_schema_hash, "
|
|
|
+ "outs.schema_ref AS output_schema_ref, "
|
|
|
+ "outs.fields AS output_fields "
|
|
|
+ "FROM public.dataflow_deployments d "
|
|
|
+ "JOIN public.dataflow_component_bindings cb "
|
|
|
+ "ON cb.dataflow_version_id = d.dataflow_version_id "
|
|
|
+ "JOIN public.rule_execution_plans p "
|
|
|
+ "ON p.component_binding_id = cb.id "
|
|
|
+ "AND p.status = 'published' "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ib "
|
|
|
+ "ON ib.id = CAST(p.plan->>'input_binding_id' AS uuid) "
|
|
|
+ "AND ib.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots ins "
|
|
|
+ "ON ins.id = ib.schema_snapshot_id "
|
|
|
+ "JOIN public.dataflow_dataset_bindings ob "
|
|
|
+ "ON ob.id = CAST(p.plan->>'output_binding_id' AS uuid) "
|
|
|
+ "AND ob.dataflow_deployment_id = d.id "
|
|
|
+ "JOIN public.data_schema_snapshots outs "
|
|
|
+ "ON outs.id = ob.schema_snapshot_id "
|
|
|
+ "WHERE d.dataflow_version_id = CAST(:version_id AS uuid) "
|
|
|
+ "AND d.environment = :environment "
|
|
|
+ "AND d.package IS NULL "
|
|
|
+ "AND EXISTS (SELECT 1 FROM public.rule_compile_evidence ce "
|
|
|
+ "WHERE ce.rule_execution_plan_id = p.id "
|
|
|
+ "AND ce.status = 'success' "
|
|
|
+ "AND ce.legacy_untrusted = FALSE "
|
|
|
+ "AND ce.plan_hash = p.plan_hash) "
|
|
|
+ "AND EXISTS (SELECT 1 FROM public.rule_test_evidence te "
|
|
|
+ "WHERE te.rule_execution_plan_id = p.id "
|
|
|
+ "AND te.status = 'success' "
|
|
|
+ "AND te.legacy_untrusted = FALSE "
|
|
|
+ "AND te.plan_hash = p.plan_hash) "
|
|
|
+ "ORDER BY cb.order_no, cb.id "
|
|
|
+ "/* deployable_release_physical_context */"
|
|
|
+ ),
|
|
|
+ {"version_id": version_id, "environment": environment},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ component_count = int(
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT COUNT(*) FROM public.dataflow_component_bindings "
|
|
|
+ "WHERE dataflow_version_id = CAST(:id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"id": version_id},
|
|
|
+ ).scalar_one()
|
|
|
+ )
|
|
|
+ if not context_rows or len(context_rows) != component_count:
|
|
|
+ raise ValueError(
|
|
|
+ "every production-line component requires a published "
|
|
|
+ "physical execution plan"
|
|
|
+ )
|
|
|
+ deployment_ids = {str(row["deployment_id"]) for row in context_rows}
|
|
|
+ if len(deployment_ids) != 1:
|
|
|
+ raise ValueError("physical execution plans do not share one deployment")
|
|
|
+ first = context_rows[0]
|
|
|
+ last = context_rows[-1]
|
|
|
+ physical_workflow_spec = self._physical_workflow_spec(package, context_rows)
|
|
|
+ return {
|
|
|
+ "id": str(version["id"]),
|
|
|
+ "dataflow_uid": str(version["dataflow_uid"]),
|
|
|
+ "version_no": int(version["version_no"]),
|
|
|
+ "status": str(version["status"]),
|
|
|
+ "package": package,
|
|
|
+ "package_hash": package_hash,
|
|
|
+ "preparatory_deployment_id": next(iter(deployment_ids)),
|
|
|
+ "physical_plan_hashes": [str(row["plan_hash"]) for row in context_rows],
|
|
|
+ "physical_workflow_spec": physical_workflow_spec,
|
|
|
+ "binding_snapshot": {
|
|
|
+ "input": {
|
|
|
+ "binding_id": str(first["input_binding_id"]),
|
|
|
+ "logical_ref": str(first["input_logical_ref"]),
|
|
|
+ "data_source_uid": str(first["input_data_source_uid"]),
|
|
|
+ "object_kind": str(first["input_object_kind"]),
|
|
|
+ "object_ref": str(first["input_object_ref"]),
|
|
|
+ "schema_snapshot_id": str(first["input_snapshot_id"]),
|
|
|
+ "schema_hash": str(first["input_schema_hash"]),
|
|
|
+ "binding_hash": str(first["input_binding_hash"]),
|
|
|
+ "access_mode": str(first["input_access_mode"]),
|
|
|
+ "dialect": str(first["input_dialect"]),
|
|
|
+ },
|
|
|
+ "output": {
|
|
|
+ "binding_id": str(last["output_binding_id"]),
|
|
|
+ "logical_ref": str(last["output_logical_ref"]),
|
|
|
+ "data_source_uid": str(last["output_data_source_uid"]),
|
|
|
+ "object_kind": str(last["output_object_kind"]),
|
|
|
+ "object_ref": str(last["output_object_ref"]),
|
|
|
+ "schema_snapshot_id": str(last["output_snapshot_id"]),
|
|
|
+ "schema_hash": str(last["output_schema_hash"]),
|
|
|
+ "binding_hash": str(last["output_binding_hash"]),
|
|
|
+ "access_mode": str(last["output_access_mode"]),
|
|
|
+ "write_mode": str(last["output_write_mode"]),
|
|
|
+ "dialect": str(last["output_dialect"]),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "schema_snapshots": {
|
|
|
+ "input": {
|
|
|
+ "id": str(first["input_snapshot_id"]),
|
|
|
+ "schema_ref": str(first["input_schema_ref"]),
|
|
|
+ "schema_hash": str(first["input_schema_hash"]),
|
|
|
+ "fields": _array(first["input_fields"], "input fields"),
|
|
|
+ },
|
|
|
+ "output": {
|
|
|
+ "id": str(last["output_snapshot_id"]),
|
|
|
+ "schema_ref": str(last["output_schema_ref"]),
|
|
|
+ "schema_hash": str(last["output_schema_hash"]),
|
|
|
+ "fields": _array(last["output_fields"], "output fields"),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _physical_workflow_spec(
|
|
|
+ package: Mapping[str, Any],
|
|
|
+ context_rows: list[Mapping[str, Any]],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ semantic = _object(package.get("workflow_spec"), "workflow spec")
|
|
|
+ physical = copy.deepcopy(semantic)
|
|
|
+ plans: dict[str, str] = {}
|
|
|
+ for row in context_rows:
|
|
|
+ binding_id = _uid(row.get("component_binding_id"), "component_binding_id")
|
|
|
+ if binding_id in plans:
|
|
|
+ raise ValueError(
|
|
|
+ "physical execution plan mapping contains duplicate components"
|
|
|
+ )
|
|
|
+ plans[binding_id] = _digest(row.get("plan_hash"), "physical_plan_hash")
|
|
|
+
|
|
|
+ mapped: set[str] = set()
|
|
|
+ for node in physical.get("nodes", []):
|
|
|
+ if not isinstance(node, dict) or node.get("type") not in {
|
|
|
+ "rule.apply",
|
|
|
+ "quality.check",
|
|
|
+ }:
|
|
|
+ continue
|
|
|
+ config = node.get("config")
|
|
|
+ if not isinstance(config, dict):
|
|
|
+ raise ValueError("physical workflow rule node config is invalid")
|
|
|
+ binding_id = _uid(
|
|
|
+ config.get("component_binding_id"),
|
|
|
+ "workflow component_binding_id",
|
|
|
+ )
|
|
|
+ if binding_id in mapped:
|
|
|
+ raise ValueError("physical workflow maps a component more than once")
|
|
|
+ if binding_id not in plans:
|
|
|
+ raise ValueError(
|
|
|
+ "physical workflow node has no published execution plan"
|
|
|
+ )
|
|
|
+ config["execution_plan_hash"] = plans[binding_id]
|
|
|
+ mapped.add(binding_id)
|
|
|
+ if mapped != set(plans):
|
|
|
+ raise ValueError(
|
|
|
+ "published physical execution plans do not exactly cover workflow nodes"
|
|
|
+ )
|
|
|
+ return physical
|
|
|
+
|
|
|
+ def create_deployment(self, snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ value = _bounded_object(
|
|
|
+ snapshot, "deployment snapshot", maximum_bytes=2_000_000
|
|
|
+ )
|
|
|
+ deployment_id = _uid(
|
|
|
+ value.get("preparatory_deployment_id"),
|
|
|
+ "preparatory_deployment_id",
|
|
|
+ )
|
|
|
+ params = {
|
|
|
+ "id": deployment_id,
|
|
|
+ "version_id": _uid(value["dataflow_version_id"], "dataflow_version_id"),
|
|
|
+ "dataflow_uid": _uid(value["dataflow_uid"], "dataflow_uid"),
|
|
|
+ "environment": _text(value["environment"], "environment", 20),
|
|
|
+ "package": _json(value["package"]),
|
|
|
+ "package_hash": _digest(value["package_hash"], "package_hash"),
|
|
|
+ "standard_version_ids": _json(value["standard_version_ids"]),
|
|
|
+ "rule_version_ids": _json(value["rule_version_ids"]),
|
|
|
+ "binding_snapshot": _json(value["binding_snapshot"]),
|
|
|
+ "binding_hash": _digest(value["binding_hash"], "binding_hash"),
|
|
|
+ "schema_snapshots": _json(value["schema_snapshots"]),
|
|
|
+ "schema_snapshot_hash": _digest(
|
|
|
+ value["schema_snapshot_hash"], "schema_snapshot_hash"
|
|
|
+ ),
|
|
|
+ "physical_plan_hashes": _json(value["physical_plan_hashes"]),
|
|
|
+ "workflow_spec": _json(value["workflow_spec"]),
|
|
|
+ "workflow_spec_hash": _digest(
|
|
|
+ value["workflow_spec_hash"], "workflow_spec_hash"
|
|
|
+ ),
|
|
|
+ "schedule_snapshot": _json(value["schedule_snapshot"]),
|
|
|
+ "schedule_hash": _digest(value["schedule_hash"], "schedule_hash"),
|
|
|
+ "actor_uid": _uid(value["actor_uid"], "actor_uid"),
|
|
|
+ "reason": _text(value["reason"], "reason", 1000),
|
|
|
+ "idempotency_key": _text(value["idempotency_key"], "idempotency_key", 200),
|
|
|
+ "correlation_id": _uid(value["correlation_id"], "correlation_id"),
|
|
|
+ }
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments SET "
|
|
|
+ "dataflow_uid = CAST(:dataflow_uid AS uuid), "
|
|
|
+ "package = CAST(:package AS jsonb), package_hash = :package_hash, "
|
|
|
+ "standard_version_ids = CAST(:standard_version_ids AS jsonb), "
|
|
|
+ "rule_version_ids = CAST(:rule_version_ids AS jsonb), "
|
|
|
+ "binding_snapshot = CAST(:binding_snapshot AS jsonb), "
|
|
|
+ "binding_hash = :binding_hash, "
|
|
|
+ "schema_snapshots = CAST(:schema_snapshots AS jsonb), "
|
|
|
+ "schema_snapshot_hash = :schema_snapshot_hash, "
|
|
|
+ "physical_plan_hashes = CAST(:physical_plan_hashes AS jsonb), "
|
|
|
+ "workflow_spec = CAST(:workflow_spec AS jsonb), "
|
|
|
+ "workflow_spec_hash = :workflow_spec_hash, "
|
|
|
+ "schedule_snapshot = CAST(:schedule_snapshot AS jsonb), "
|
|
|
+ "schedule_hash = :schedule_hash, "
|
|
|
+ "created_by = CAST(:actor_uid AS uuid), "
|
|
|
+ "create_reason = :reason, "
|
|
|
+ "create_idempotency_key = :idempotency_key, "
|
|
|
+ "correlation_id = CAST(:correlation_id AS uuid), "
|
|
|
+ "status = 'draft', lock_version = lock_version + 1, "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND dataflow_version_id = CAST(:version_id AS uuid) "
|
|
|
+ "AND environment = :environment "
|
|
|
+ "AND package IS NULL "
|
|
|
+ "RETURNING id"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ replay = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ self._deployment_select()
|
|
|
+ + "WHERE d.created_by = CAST(:actor_uid AS uuid) "
|
|
|
+ "AND d.environment = :environment "
|
|
|
+ "AND d.create_idempotency_key = :idempotency_key"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if replay is None:
|
|
|
+ raise ValueError(
|
|
|
+ "preparatory deployment is unavailable or already claimed"
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ str(replay["dataflow_version_id"]) != params["version_id"]
|
|
|
+ or str(replay["package_hash"]) != params["package_hash"]
|
|
|
+ or str(replay["binding_hash"]) != params["binding_hash"]
|
|
|
+ or str(replay["schedule_hash"]) != params["schedule_hash"]
|
|
|
+ ):
|
|
|
+ raise ValueError("deployment idempotency key has conflicting content")
|
|
|
+ return self._deployment_result(replay)
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_deployment_transitions "
|
|
|
+ "(id, deployment_id, from_status, to_status, actor_uid, "
|
|
|
+ "reason, correlation_id, detail) VALUES "
|
|
|
+ "(CAST(:transition_id AS uuid), CAST(:id AS uuid), "
|
|
|
+ "'disabled', 'draft', CAST(:actor_uid AS uuid), :reason, "
|
|
|
+ "CAST(:correlation_id AS uuid), "
|
|
|
+ '\'{"source":"preparatory_physical_gate"}\'::jsonb)'
|
|
|
+ ),
|
|
|
+ {**params, "transition_id": new_governance_uid()},
|
|
|
+ )
|
|
|
+ return self.get_deployment(deployment_id)
|
|
|
+
|
|
|
+ def get_deployment(
|
|
|
+ self, deployment_id: str, *, for_update: bool = False
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ suffix = " FOR UPDATE OF d" if for_update else ""
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ self._deployment_select()
|
|
|
+ + "WHERE d.id = CAST(:id AS uuid)"
|
|
|
+ + suffix
|
|
|
+ ),
|
|
|
+ {"id": _uid(deployment_id, "deployment_id")},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None or row["package"] is None:
|
|
|
+ raise ValueError("governed deployment was not found")
|
|
|
+ return self._deployment_result(row)
|
|
|
+
|
|
|
+ def claim_operation(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ action: str,
|
|
|
+ idempotency_key: str,
|
|
|
+ actor_uid: str,
|
|
|
+ correlation_id: str,
|
|
|
+ reason: str,
|
|
|
+ request_payload: dict[str, Any],
|
|
|
+ ) -> tuple[bool, dict[str, Any]]:
|
|
|
+ if action not in {
|
|
|
+ "deploy_disabled",
|
|
|
+ "run_canary",
|
|
|
+ "activate",
|
|
|
+ "rollback",
|
|
|
+ "execute_active",
|
|
|
+ }:
|
|
|
+ raise ValueError("deployment operation is invalid")
|
|
|
+ params = {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "deployment_id": _uid(deployment_id, "deployment_id"),
|
|
|
+ "action": action,
|
|
|
+ "key": _text(idempotency_key, "idempotency_key", 200),
|
|
|
+ "actor_uid": _uid(actor_uid, "actor_uid"),
|
|
|
+ "correlation_id": _uid(correlation_id, "correlation_id"),
|
|
|
+ "reason": _text(reason, "reason", 1000),
|
|
|
+ "request": _json(
|
|
|
+ _bounded_object(
|
|
|
+ request_payload,
|
|
|
+ "deployment operation request",
|
|
|
+ maximum_bytes=131_072,
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ "request_hash": _canonical_hash(request_payload),
|
|
|
+ "owner_token": new_governance_uid(),
|
|
|
+ "lease_seconds": 1200,
|
|
|
+ }
|
|
|
+ scope = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT dataflow_uid::text AS dataflow_uid, environment "
|
|
|
+ "FROM public.dataflow_deployments "
|
|
|
+ "WHERE id = CAST(:deployment_id AS uuid)"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one()
|
|
|
+ )
|
|
|
+ params.update(scope)
|
|
|
+ self.session.execute(
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtextextended(:resource_key, 0))"),
|
|
|
+ {"resource_key": (f"{scope['dataflow_uid']}:{scope['environment']}")},
|
|
|
+ )
|
|
|
+ scope_unknown = list(
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT o.id::text AS id, o.deployment_id::text, o.action, "
|
|
|
+ "o.idempotency_key, o.status, o.actor_uid::text, "
|
|
|
+ "o.correlation_id::text, o.reason, o.request, "
|
|
|
+ "o.request_hash, o.result, o.result_hash, o.error_code, "
|
|
|
+ "o.owner_token::text AS owner_token, o.attempt_epoch, "
|
|
|
+ "o.fencing_epoch, o.lease_expires_at "
|
|
|
+ "FROM public.dataflow_deployment_operations o "
|
|
|
+ "JOIN public.dataflow_deployments d ON d.id = o.deployment_id "
|
|
|
+ "WHERE d.dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND d.environment = :environment AND o.status = 'unknown' "
|
|
|
+ "ORDER BY o.started_at ASC, o.id ASC LIMIT 2"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ existing = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, deployment_id::text, action, "
|
|
|
+ "idempotency_key, status, actor_uid::text, "
|
|
|
+ "correlation_id::text, reason, request, request_hash, "
|
|
|
+ "result, result_hash, error_code, "
|
|
|
+ "owner_token::text AS owner_token, attempt_epoch, "
|
|
|
+ "fencing_epoch, lease_expires_at "
|
|
|
+ "FROM public.dataflow_deployment_operations "
|
|
|
+ "WHERE deployment_id = CAST(:deployment_id AS uuid) "
|
|
|
+ "AND action = :action AND idempotency_key = :key"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ row = dict(existing)
|
|
|
+ if (
|
|
|
+ row["status"] == "claimed"
|
|
|
+ and row.get("lease_expires_at") is not None
|
|
|
+ and row["lease_expires_at"] <= datetime.now(UTC)
|
|
|
+ ):
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'unknown', "
|
|
|
+ "error_code = 'operation_lease_expired', "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed'"
|
|
|
+ ),
|
|
|
+ {"id": row["id"]},
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "DELETE FROM public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"id": row["id"]},
|
|
|
+ )
|
|
|
+ row["status"] = "unknown"
|
|
|
+ row["error_code"] = "operation_lease_expired"
|
|
|
+ claimed = False
|
|
|
+ elif scope_unknown:
|
|
|
+ # A response-loss outcome is a scope-wide safety barrier. Return
|
|
|
+ # the oldest unresolved operation so callers can reconcile that
|
|
|
+ # exact deployment/action/key before any new external mutation.
|
|
|
+ row = dict(scope_unknown[0])
|
|
|
+ row["scope_unknown_count"] = len(scope_unknown)
|
|
|
+ claimed = False
|
|
|
+ else:
|
|
|
+ lease = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT l.operation_id::text AS id, "
|
|
|
+ "o.deployment_id::text, o.action, o.idempotency_key, "
|
|
|
+ "o.status, "
|
|
|
+ "o.actor_uid::text, o.correlation_id::text, o.reason, "
|
|
|
+ "o.request, o.request_hash, o.result, o.result_hash, "
|
|
|
+ "o.error_code, o.owner_token::text AS owner_token, "
|
|
|
+ "o.attempt_epoch, o.fencing_epoch, o.lease_expires_at "
|
|
|
+ "FROM public.dataflow_deployment_operation_leases l "
|
|
|
+ "JOIN public.dataflow_deployment_operations o "
|
|
|
+ "ON o.id = l.operation_id "
|
|
|
+ "WHERE l.dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
+ "AND l.environment = :environment FOR UPDATE"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if lease is not None and lease["lease_expires_at"] > datetime.now(UTC):
|
|
|
+ row = lease
|
|
|
+ claimed = False
|
|
|
+ else:
|
|
|
+ if lease is not None:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'unknown', "
|
|
|
+ "error_code = 'operation_lease_expired', "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed'"
|
|
|
+ ),
|
|
|
+ {"id": lease["id"]},
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "DELETE FROM public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"id": lease["id"]},
|
|
|
+ )
|
|
|
+ row = dict(lease)
|
|
|
+ row["status"] = "unknown"
|
|
|
+ row["error_code"] = "operation_lease_expired"
|
|
|
+ claimed = False
|
|
|
+ else:
|
|
|
+ epoch = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT nextval("
|
|
|
+ "'public.dataflow_deployment_fencing_epoch_seq')"
|
|
|
+ )
|
|
|
+ ).scalar_one()
|
|
|
+ params["fencing_epoch"] = int(epoch)
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_deployment_operations "
|
|
|
+ "(id, deployment_id, action, idempotency_key, status, "
|
|
|
+ "actor_uid, correlation_id, reason, request, "
|
|
|
+ "request_hash, owner_token, attempt_epoch, "
|
|
|
+ "fencing_epoch, lease_expires_at) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
|
|
|
+ ":action, :key, 'claimed', CAST(:actor_uid AS uuid), "
|
|
|
+ "CAST(:correlation_id AS uuid), :reason, "
|
|
|
+ "CAST(:request AS jsonb), :request_hash, "
|
|
|
+ "CAST(:owner_token AS uuid), 1, :fencing_epoch, "
|
|
|
+ "CURRENT_TIMESTAMP + make_interval(secs => :lease_seconds)) "
|
|
|
+ "RETURNING id::text AS id, deployment_id::text, "
|
|
|
+ "action, idempotency_key, status, actor_uid::text, "
|
|
|
+ "correlation_id::text, reason, request, request_hash, "
|
|
|
+ "result, result_hash, error_code, "
|
|
|
+ "owner_token::text AS owner_token, "
|
|
|
+ "attempt_epoch, fencing_epoch, lease_expires_at"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one()
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_deployment_operation_leases "
|
|
|
+ "(dataflow_uid, environment, deployment_id, operation_id, "
|
|
|
+ "owner_token, fencing_epoch, lease_expires_at) VALUES "
|
|
|
+ "(CAST(:dataflow_uid AS uuid), :environment, "
|
|
|
+ "CAST(:deployment_id AS uuid), CAST(:id AS uuid), "
|
|
|
+ "CAST(:owner_token AS uuid), :fencing_epoch, "
|
|
|
+ "CURRENT_TIMESTAMP + make_interval(secs => :lease_seconds))"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ claimed = True
|
|
|
+ result = dict(row)
|
|
|
+ result["request"] = _object(result["request"], "deployment operation request")
|
|
|
+ if _canonical_hash(result["request"]) != result.get("request_hash"):
|
|
|
+ raise ValueError("deployment operation request has drifted")
|
|
|
+ same_key = (
|
|
|
+ str(result.get("deployment_id")) == params["deployment_id"]
|
|
|
+ and result.get("action") == params["action"]
|
|
|
+ and result.get("idempotency_key") == params["key"]
|
|
|
+ )
|
|
|
+ if same_key and (
|
|
|
+ str(result.get("actor_uid")) != params["actor_uid"]
|
|
|
+ or str(result.get("request_hash")) != params["request_hash"]
|
|
|
+ or str(result.get("reason")) != params["reason"]
|
|
|
+ ):
|
|
|
+ raise ValueError(
|
|
|
+ "deployment idempotency key is bound to another actor or request"
|
|
|
+ )
|
|
|
+ if result.get("result") is not None:
|
|
|
+ result["result"] = _object(result["result"], "deployment operation result")
|
|
|
+ if _canonical_hash(result["result"]) != result.get("result_hash"):
|
|
|
+ raise ValueError("deployment operation result has drifted")
|
|
|
+ return claimed, result
|
|
|
+
|
|
|
+ def commit(self) -> None:
|
|
|
+ self.session.commit()
|
|
|
+
|
|
|
+ def get_operation(
|
|
|
+ self, deployment_id: str, action: str, idempotency_key: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, deployment_id::text AS deployment_id, "
|
|
|
+ "action, status, actor_uid::text AS actor_uid, "
|
|
|
+ "correlation_id::text AS correlation_id, reason, request, "
|
|
|
+ "request_hash, result, result_hash, error_code "
|
|
|
+ ", owner_token::text AS owner_token, attempt_epoch, "
|
|
|
+ "fencing_epoch, lease_expires_at "
|
|
|
+ "FROM public.dataflow_deployment_operations "
|
|
|
+ "WHERE deployment_id = CAST(:deployment_id AS uuid) "
|
|
|
+ "AND action = :action AND idempotency_key = :key"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "deployment_id": _uid(deployment_id, "deployment_id"),
|
|
|
+ "action": _text(action, "action", 30),
|
|
|
+ "key": _text(idempotency_key, "idempotency_key", 200),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("deployment operation was not found")
|
|
|
+ result = dict(row)
|
|
|
+ result["request"] = _object(result["request"], "deployment operation request")
|
|
|
+ if _canonical_hash(result["request"]) != result["request_hash"]:
|
|
|
+ raise ValueError("deployment operation request has drifted")
|
|
|
+ if result.get("result") is not None:
|
|
|
+ result["result"] = _object(result["result"], "deployment operation result")
|
|
|
+ if _canonical_hash(result["result"]) != result["result_hash"]:
|
|
|
+ raise ValueError("deployment operation result has drifted")
|
|
|
+ return result
|
|
|
+
|
|
|
+ def recover_operation(
|
|
|
+ self, deployment_id: str, action: str, idempotency_key: str, actor_uid: str
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ operation = self.get_operation(deployment_id, action, idempotency_key)
|
|
|
+ if operation["actor_uid"] != _uid(actor_uid, "actor_uid"):
|
|
|
+ raise ValueError("operation is bound to another actor")
|
|
|
+ if operation["status"] == "completed":
|
|
|
+ return operation
|
|
|
+ expires = operation.get("lease_expires_at")
|
|
|
+ if (
|
|
|
+ operation["status"] == "claimed"
|
|
|
+ and expires is not None
|
|
|
+ and expires > datetime.now(UTC)
|
|
|
+ ):
|
|
|
+ raise ValueError("deployment operation is already in progress")
|
|
|
+ deployment = self.get_deployment(deployment_id)
|
|
|
+ self.session.execute(
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtextextended(:resource_key, 0))"),
|
|
|
+ {
|
|
|
+ "resource_key": (
|
|
|
+ f"{deployment['dataflow_uid']}:{deployment['environment']}"
|
|
|
+ )
|
|
|
+ },
|
|
|
+ )
|
|
|
+ oldest_unresolved = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT o.id::text FROM "
|
|
|
+ "public.dataflow_deployment_operations o "
|
|
|
+ "JOIN public.dataflow_deployments d "
|
|
|
+ "ON d.id = o.deployment_id "
|
|
|
+ "WHERE d.dataflow_uid = CAST(:uid AS uuid) "
|
|
|
+ "AND d.environment = :env "
|
|
|
+ "AND (o.status = 'unknown' OR "
|
|
|
+ "(o.status = 'claimed' "
|
|
|
+ "AND o.lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
+ "ORDER BY o.started_at ASC, o.id ASC "
|
|
|
+ "LIMIT 1 FOR UPDATE OF o"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "uid": deployment["dataflow_uid"],
|
|
|
+ "env": deployment["environment"],
|
|
|
+ },
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if (
|
|
|
+ oldest_unresolved is not None
|
|
|
+ and str(oldest_unresolved) != str(operation["id"])
|
|
|
+ ):
|
|
|
+ raise ValueError(
|
|
|
+ "an older deployment operation requires reconciliation first"
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "DELETE FROM public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE dataflow_uid = CAST(:uid AS uuid) AND environment = :env "
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {"uid": deployment["dataflow_uid"], "env": deployment["environment"]},
|
|
|
+ )
|
|
|
+ active_lease = self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT operation_id::text FROM "
|
|
|
+ "public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE dataflow_uid = CAST(:uid AS uuid) "
|
|
|
+ "AND environment = :env AND lease_expires_at > CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {"uid": deployment["dataflow_uid"], "env": deployment["environment"]},
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ if active_lease is not None:
|
|
|
+ raise ValueError("deployment operation is already in progress")
|
|
|
+ owner = new_governance_uid()
|
|
|
+ epoch = int(
|
|
|
+ self.session.execute(
|
|
|
+ text("SELECT nextval('public.dataflow_deployment_fencing_epoch_seq')")
|
|
|
+ ).scalar_one()
|
|
|
+ )
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'claimed', error_code = NULL, "
|
|
|
+ "owner_token = CAST(:owner AS uuid), "
|
|
|
+ "attempt_epoch = attempt_epoch + 1, fencing_epoch = :epoch, "
|
|
|
+ "lease_expires_at = CURRENT_TIMESTAMP + interval '20 minutes', "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND (status = 'unknown' OR (status = 'claimed' "
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP))"
|
|
|
+ ),
|
|
|
+ {"id": operation["id"], "owner": owner, "epoch": epoch},
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("recoverable operation changed concurrently")
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_deployment_operation_leases "
|
|
|
+ "(dataflow_uid, environment, deployment_id, operation_id, "
|
|
|
+ "owner_token, fencing_epoch, lease_expires_at) VALUES "
|
|
|
+ "(CAST(:uid AS uuid), :env, CAST(:deployment AS uuid), "
|
|
|
+ "CAST(:operation AS uuid), CAST(:owner AS uuid), :epoch, "
|
|
|
+ "CURRENT_TIMESTAMP + interval '20 minutes')"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "uid": deployment["dataflow_uid"],
|
|
|
+ "env": deployment["environment"],
|
|
|
+ "deployment": deployment_id,
|
|
|
+ "operation": operation["id"],
|
|
|
+ "owner": owner,
|
|
|
+ "epoch": epoch,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return self.get_operation(deployment_id, action, idempotency_key)
|
|
|
+
|
|
|
+ def reclaim_unknown_operation(self, operation_id: str) -> None:
|
|
|
+ raise RuntimeError("use recover_operation for fenced recovery")
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _fence_params(operation: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "id": _uid(operation["id"], "operation_id"),
|
|
|
+ "owner": _uid(operation["owner_token"], "owner_token"),
|
|
|
+ "epoch": int(operation["fencing_epoch"]),
|
|
|
+ }
|
|
|
+
|
|
|
+ def owns_operation_lease(self, operation: Mapping[str, Any]) -> bool:
|
|
|
+ return bool(
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT EXISTS (SELECT 1 FROM "
|
|
|
+ "public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid) "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP)"
|
|
|
+ ),
|
|
|
+ self._fence_params(operation),
|
|
|
+ ).scalar_one()
|
|
|
+ )
|
|
|
+
|
|
|
+ def renew_operation_lease(
|
|
|
+ self, operation: Mapping[str, Any], lease_seconds: int
|
|
|
+ ) -> bool:
|
|
|
+ seconds = int(lease_seconds)
|
|
|
+ if seconds < 60 or seconds > 3600:
|
|
|
+ raise ValueError("operation lease is outside policy")
|
|
|
+ params = {**self._fence_params(operation), "seconds": seconds}
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operation_leases "
|
|
|
+ "SET lease_expires_at = CURRENT_TIMESTAMP "
|
|
|
+ "+ make_interval(secs => :seconds), "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid) "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ return False
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET lease_expires_at = CURRENT_TIMESTAMP "
|
|
|
+ "+ make_interval(secs => :seconds), "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ return True
|
|
|
+
|
|
|
+ def _assert_operation_lease(self, operation: Mapping[str, Any]) -> None:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT operation_id FROM "
|
|
|
+ "public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid) "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP FOR UPDATE"
|
|
|
+ ),
|
|
|
+ self._fence_params(operation),
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
+
|
|
|
+ def lock_operation_lease(self, operation: Mapping[str, Any]) -> bool:
|
|
|
+ self._assert_operation_lease(operation)
|
|
|
+ return True
|
|
|
+
|
|
|
+ def _release_operation_lease(self, operation: Mapping[str, Any]) -> None:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "DELETE FROM public.dataflow_deployment_operation_leases "
|
|
|
+ "WHERE operation_id = CAST(:id AS uuid) "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch"
|
|
|
+ ),
|
|
|
+ self._fence_params(operation),
|
|
|
+ )
|
|
|
+
|
|
|
+ def fail_operation(self, operation: Mapping[str, Any], error_code: str) -> None:
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'failed', error_code = :error_code, "
|
|
|
+ "failed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed' "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ **self._fence_params(operation),
|
|
|
+ "error_code": _text(error_code, "error_code", 100),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) == 1:
|
|
|
+ self._release_operation_lease(operation)
|
|
|
+
|
|
|
+ def mark_operation_unknown(
|
|
|
+ self, operation: Mapping[str, Any], error_code: str
|
|
|
+ ) -> None:
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'unknown', error_code = :error_code, "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed' "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ **self._fence_params(operation),
|
|
|
+ "error_code": _text(error_code, "error_code", 100),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) == 1:
|
|
|
+ self._release_operation_lease(operation)
|
|
|
+
|
|
|
+ def _complete_deployment_operation(
|
|
|
+ self, operation: Mapping[str, Any], result: dict[str, Any]
|
|
|
+ ) -> None:
|
|
|
+ normalized = _bounded_object(result, "operation result")
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET status = 'completed', result = CAST(:result AS jsonb), "
|
|
|
+ "result_hash = :result_hash, completed_at = CURRENT_TIMESTAMP, "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed' "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ **self._fence_params(operation),
|
|
|
+ "result": _json(normalized),
|
|
|
+ "result_hash": _canonical_hash(normalized),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
+ self._release_operation_lease(operation)
|
|
|
+
|
|
|
+ def complete_active_execution(
|
|
|
+ self,
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ result: dict[str, Any],
|
|
|
+ ) -> None:
|
|
|
+ if operation.get("action") != "execute_active":
|
|
|
+ raise ValueError("active execution operation is invalid")
|
|
|
+ self._complete_deployment_operation(operation, result)
|
|
|
+
|
|
|
+ def record_active_execution_receipt(
|
|
|
+ self,
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ receipt: dict[str, Any],
|
|
|
+ ) -> None:
|
|
|
+ """Fence and persist the engine identity before terminal polling."""
|
|
|
+
|
|
|
+ if operation.get("action") != "execute_active":
|
|
|
+ raise ValueError("active execution operation is invalid")
|
|
|
+ normalized = _bounded_object(receipt, "active execution receipt")
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployment_operations "
|
|
|
+ "SET result = CAST(:result AS jsonb), "
|
|
|
+ "result_hash = :result_hash, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed' "
|
|
|
+ "AND owner_token = CAST(:owner AS uuid) "
|
|
|
+ "AND fencing_epoch = :epoch "
|
|
|
+ "AND lease_expires_at > CURRENT_TIMESTAMP"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ **self._fence_params(operation),
|
|
|
+ "result": _json(normalized),
|
|
|
+ "result_hash": _canonical_hash(normalized),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
+
|
|
|
+ def _operation_context(self, operation_id: str):
|
|
|
+ return (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT actor_uid::text AS actor_uid, "
|
|
|
+ "correlation_id::text AS correlation_id, reason "
|
|
|
+ "FROM public.dataflow_deployment_operations "
|
|
|
+ "WHERE id = CAST(:id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"id": _uid(operation_id, "operation_id")},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one()
|
|
|
+ )
|
|
|
+
|
|
|
+ def _record_deployment_transition(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ operation_id: str,
|
|
|
+ from_status: str,
|
|
|
+ to_status: str,
|
|
|
+ detail: dict[str, Any],
|
|
|
+ ) -> None:
|
|
|
+ operation = self._operation_context(operation_id)
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_deployment_transitions "
|
|
|
+ "(id, deployment_id, operation_id, from_status, to_status, "
|
|
|
+ "actor_uid, reason, correlation_id, detail) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
|
|
|
+ "CAST(:operation_id AS uuid), :from_status, :to_status, "
|
|
|
+ "CAST(:actor_uid AS uuid), :reason, "
|
|
|
+ "CAST(:correlation_id AS uuid), CAST(:detail AS jsonb))"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": new_governance_uid(),
|
|
|
+ "deployment_id": _uid(deployment_id, "deployment_id"),
|
|
|
+ "operation_id": _uid(operation_id, "operation_id"),
|
|
|
+ "from_status": from_status,
|
|
|
+ "to_status": to_status,
|
|
|
+ "actor_uid": operation["actor_uid"],
|
|
|
+ "reason": operation["reason"],
|
|
|
+ "correlation_id": operation["correlation_id"],
|
|
|
+ "detail": _json(detail),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ def mark_disabled(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ expected_version: int,
|
|
|
+ engine: dict[str, Any],
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ self._assert_operation_lease(operation)
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ current = self.get_deployment(deployment, for_update=True)
|
|
|
+ if current["lock_version"] != expected_version or current["status"] not in {
|
|
|
+ "draft",
|
|
|
+ "failed",
|
|
|
+ }:
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments SET status = 'disabled', "
|
|
|
+ "engine_namespace = :namespace, "
|
|
|
+ "engine_definition_id = :definition_id, "
|
|
|
+ "engine_revision = :revision, "
|
|
|
+ "engine_definition_hash = :definition_hash, "
|
|
|
+ "engine_definition_hash_version = :definition_hash_version, "
|
|
|
+ "deploy_receipt = CAST(:receipt AS jsonb), "
|
|
|
+ "failure_code = NULL, lock_version = lock_version + 1, "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND lock_version = :version"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": deployment,
|
|
|
+ "version": expected_version,
|
|
|
+ "namespace": _text(engine["namespace"], "namespace", 200),
|
|
|
+ "definition_id": _text(engine["definition_id"], "definition_id", 200),
|
|
|
+ "revision": (
|
|
|
+ None
|
|
|
+ if engine.get("revision") is None
|
|
|
+ else str(engine["revision"])[:200]
|
|
|
+ ),
|
|
|
+ "definition_hash": _digest(
|
|
|
+ engine["definition_hash"], "definition_hash"
|
|
|
+ ),
|
|
|
+ "definition_hash_version": _text(
|
|
|
+ engine.get("definition_hash_version"),
|
|
|
+ "definition_hash_version",
|
|
|
+ 30,
|
|
|
+ ),
|
|
|
+ "receipt": _json(
|
|
|
+ _bounded_object(engine.get("receipt") or {}, "deploy receipt")
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ result = self.get_deployment(deployment)
|
|
|
+ self._record_deployment_transition(
|
|
|
+ deployment,
|
|
|
+ operation["id"],
|
|
|
+ current["status"],
|
|
|
+ "disabled",
|
|
|
+ {
|
|
|
+ "engine_namespace": result["engine_namespace"],
|
|
|
+ "engine_definition_id": result["engine_definition_id"],
|
|
|
+ "engine_definition_hash": result["engine_definition_hash"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self._complete_deployment_operation(operation, result)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def re_attest_engine_definition(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ expected_version: int,
|
|
|
+ *,
|
|
|
+ definition_hash: str,
|
|
|
+ revision: Any,
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ current = self.get_deployment(deployment, for_update=True)
|
|
|
+ if current["lock_version"] != expected_version:
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ old_evidence = current.get("canary_evidence_id")
|
|
|
+ if old_evidence:
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_canary_evidence "
|
|
|
+ "SET status = 'failed' "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'passed'"
|
|
|
+ ),
|
|
|
+ {"id": old_evidence},
|
|
|
+ )
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments "
|
|
|
+ "SET engine_definition_hash = :definition_hash, "
|
|
|
+ "engine_definition_hash_version = 'kestra_canonical_v2', "
|
|
|
+ "engine_revision = :revision, canary_evidence_id = NULL, "
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND lock_version = :version"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": deployment,
|
|
|
+ "version": expected_version,
|
|
|
+ "definition_hash": _digest(definition_hash, "definition_hash"),
|
|
|
+ "revision": None if revision is None else str(revision)[:200],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return self.get_deployment(deployment)
|
|
|
+
|
|
|
+ def record_canary(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ expected_version: int,
|
|
|
+ evidence: dict[str, Any],
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ self._assert_operation_lease(operation)
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ current = self.get_deployment(deployment, for_update=True)
|
|
|
+ if current["lock_version"] != expected_version or current["status"] not in {
|
|
|
+ "disabled",
|
|
|
+ "canary",
|
|
|
+ }:
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ value = _bounded_object(evidence, "canary evidence")
|
|
|
+ params = {
|
|
|
+ **value,
|
|
|
+ "physical_plan_hashes": _json(value["physical_plan_hashes"]),
|
|
|
+ "engine_response": _json(value.get("engine_response") or {}),
|
|
|
+ }
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "INSERT INTO public.dataflow_canary_evidence "
|
|
|
+ "(id, deployment_id, execution_id, status, package_hash, "
|
|
|
+ "binding_hash, schema_snapshot_hash, physical_plan_hashes, "
|
|
|
+ "workflow_spec_hash, schedule_hash, engine_definition_hash, "
|
|
|
+ "engine_definition_hash_version, "
|
|
|
+ "verified_by, engine_response, expires_at) VALUES "
|
|
|
+ "(CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
|
|
|
+ ":execution_id, :status, :package_hash, :binding_hash, "
|
|
|
+ ":schema_snapshot_hash, CAST(:physical_plan_hashes AS jsonb), "
|
|
|
+ ":workflow_spec_hash, :schedule_hash, "
|
|
|
+ ":engine_definition_hash, :engine_definition_hash_version, "
|
|
|
+ "CAST(:verified_by AS uuid), "
|
|
|
+ "CAST(:engine_response AS jsonb), CAST(:expires_at AS timestamptz))"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ next_status = "canary" if value["status"] == "passed" else current["status"]
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments SET status = :status, "
|
|
|
+ "canary_evidence_id = CASE WHEN :passed THEN CAST(:evidence_id AS uuid) "
|
|
|
+ "ELSE canary_evidence_id END, "
|
|
|
+ "lock_version = lock_version + 1, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND lock_version = :version"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": deployment,
|
|
|
+ "version": expected_version,
|
|
|
+ "status": next_status,
|
|
|
+ "passed": value["status"] == "passed",
|
|
|
+ "evidence_id": value["id"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ result = self.get_canary_evidence(value["id"])
|
|
|
+ self._record_deployment_transition(
|
|
|
+ deployment,
|
|
|
+ operation["id"],
|
|
|
+ current["status"],
|
|
|
+ next_status,
|
|
|
+ {
|
|
|
+ "canary_evidence_id": result["id"],
|
|
|
+ "execution_id": result["execution_id"],
|
|
|
+ "evidence_status": result["status"],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self._complete_deployment_operation(operation, result)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def get_canary_evidence(self, evidence_id: str) -> dict[str, Any]:
|
|
|
+ row = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, deployment_id::text AS deployment_id, "
|
|
|
+ "execution_id, status, package_hash, binding_hash, "
|
|
|
+ "schema_snapshot_hash, physical_plan_hashes, "
|
|
|
+ "workflow_spec_hash, schedule_hash, engine_definition_hash, "
|
|
|
+ "engine_definition_hash_version, "
|
|
|
+ "verified_by::text AS verified_by, engine_response, "
|
|
|
+ "expires_at, created_at "
|
|
|
+ "FROM public.dataflow_canary_evidence "
|
|
|
+ "WHERE id = CAST(:id AS uuid)"
|
|
|
+ ),
|
|
|
+ {"id": _uid(evidence_id, "evidence_id")},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if row is None:
|
|
|
+ raise ValueError("canary evidence was not found")
|
|
|
+ result = dict(row)
|
|
|
+ result["physical_plan_hashes"] = _array(
|
|
|
+ result["physical_plan_hashes"], "physical plan hashes"
|
|
|
+ )
|
|
|
+ result["engine_response"] = _object(
|
|
|
+ result["engine_response"], "engine response"
|
|
|
+ )
|
|
|
+ for key in ("expires_at", "created_at"):
|
|
|
+ if hasattr(result[key], "isoformat"):
|
|
|
+ result[key] = result[key].isoformat()
|
|
|
+ return result
|
|
|
+
|
|
|
+ def activate_atomic(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ expected_version: int,
|
|
|
+ evidence_id: str,
|
|
|
+ actor_uid: str,
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ self._assert_operation_lease(operation)
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ current = self.get_deployment(deployment, for_update=True)
|
|
|
+ if current["lock_version"] != expected_version or current["status"] != "canary":
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ previous = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ self._deployment_select()
|
|
|
+ + "WHERE d.dataflow_uid = CAST(:uid AS uuid) "
|
|
|
+ "AND d.environment = :environment AND d.status = 'active' "
|
|
|
+ "AND d.id <> CAST(:id AS uuid) FOR UPDATE OF d"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "uid": current["dataflow_uid"],
|
|
|
+ "environment": current["environment"],
|
|
|
+ "id": deployment,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ previous_id = None
|
|
|
+ if previous is not None:
|
|
|
+ previous_id = str(previous["id"])
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments "
|
|
|
+ "SET status = 'superseded', superseded_at = CURRENT_TIMESTAMP, "
|
|
|
+ "lock_version = lock_version + 1, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'active'"
|
|
|
+ ),
|
|
|
+ {"id": previous_id},
|
|
|
+ )
|
|
|
+ updated = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments SET status = 'active', "
|
|
|
+ "previous_active_deployment_id = CAST(:previous_id AS uuid), "
|
|
|
+ "canary_evidence_id = CAST(:evidence_id AS uuid), "
|
|
|
+ "activated_by = CAST(:actor_uid AS uuid), "
|
|
|
+ "activated_at = CURRENT_TIMESTAMP, "
|
|
|
+ "lock_version = lock_version + 1, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND lock_version = :version "
|
|
|
+ "AND status = 'canary'"
|
|
|
+ ),
|
|
|
+ {
|
|
|
+ "id": deployment,
|
|
|
+ "version": expected_version,
|
|
|
+ "previous_id": previous_id,
|
|
|
+ "evidence_id": _uid(evidence_id, "evidence_id"),
|
|
|
+ "actor_uid": actor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if getattr(updated, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ result = self.get_deployment(deployment)
|
|
|
+ if previous_id:
|
|
|
+ self._record_deployment_transition(
|
|
|
+ previous_id,
|
|
|
+ operation["id"],
|
|
|
+ "active",
|
|
|
+ "superseded",
|
|
|
+ {"superseded_by": deployment},
|
|
|
+ )
|
|
|
+ self._record_deployment_transition(
|
|
|
+ deployment,
|
|
|
+ operation["id"],
|
|
|
+ "canary",
|
|
|
+ "active",
|
|
|
+ {
|
|
|
+ "canary_evidence_id": evidence_id,
|
|
|
+ "previous_active_deployment_id": previous_id,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ self._complete_deployment_operation(operation, result)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def rollback_atomic(
|
|
|
+ self,
|
|
|
+ deployment_id: str,
|
|
|
+ expected_version: int,
|
|
|
+ actor_uid: str,
|
|
|
+ operation: Mapping[str, Any],
|
|
|
+ ) -> dict[str, Any]:
|
|
|
+ self._assert_operation_lease(operation)
|
|
|
+ deployment = _uid(deployment_id, "deployment_id")
|
|
|
+ actor = _uid(actor_uid, "actor_uid")
|
|
|
+ current = self.get_deployment(deployment, for_update=True)
|
|
|
+ if (
|
|
|
+ current["lock_version"] != expected_version
|
|
|
+ or current["status"] not in {"active", "failed"}
|
|
|
+ or not current["previous_active_deployment_id"]
|
|
|
+ ):
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
+ previous = self.get_deployment(
|
|
|
+ current["previous_active_deployment_id"], for_update=True
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ previous["status"] != "superseded"
|
|
|
+ or previous["dataflow_uid"] != current["dataflow_uid"]
|
|
|
+ or previous["environment"] != current["environment"]
|
|
|
+ ):
|
|
|
+ raise ValueError("previous deployment snapshot is not restorable")
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments "
|
|
|
+ "SET status = 'rolled_back', rolled_back_at = CURRENT_TIMESTAMP, "
|
|
|
+ "lock_version = lock_version + 1, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND lock_version = :version"
|
|
|
+ ),
|
|
|
+ {"id": deployment, "version": expected_version},
|
|
|
+ )
|
|
|
+ restored = self.session.execute(
|
|
|
+ text(
|
|
|
+ "UPDATE public.dataflow_deployments "
|
|
|
+ "SET status = 'active', activated_by = CAST(:actor_uid AS uuid), "
|
|
|
+ "activated_at = CURRENT_TIMESTAMP, superseded_at = NULL, "
|
|
|
+ "lock_version = lock_version + 1, updated_at = CURRENT_TIMESTAMP "
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'superseded'"
|
|
|
+ ),
|
|
|
+ {"id": previous["id"], "actor_uid": actor},
|
|
|
+ )
|
|
|
+ if getattr(restored, "rowcount", 0) != 1:
|
|
|
+ raise ValueError("previous deployment state changed concurrently")
|
|
|
+ rolled_back = self.get_deployment(deployment)
|
|
|
+ active = self.get_deployment(previous["id"])
|
|
|
+ self._record_deployment_transition(
|
|
|
+ deployment,
|
|
|
+ operation["id"],
|
|
|
+ current["status"],
|
|
|
+ "rolled_back",
|
|
|
+ {"restored_deployment_id": active["id"]},
|
|
|
+ )
|
|
|
+ self._record_deployment_transition(
|
|
|
+ active["id"],
|
|
|
+ operation["id"],
|
|
|
+ "superseded",
|
|
|
+ "active",
|
|
|
+ {"rollback_source_deployment_id": deployment},
|
|
|
+ )
|
|
|
+ result = {"rolled_back": rolled_back, "active": active}
|
|
|
+ self._complete_deployment_operation(operation, result)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def list_deployments(self, environment: str | None = None) -> list[dict[str, Any]]:
|
|
|
+ where = "WHERE d.package IS NOT NULL "
|
|
|
+ params: dict[str, Any] = {}
|
|
|
+ if environment is not None:
|
|
|
+ where += "AND d.environment = :environment "
|
|
|
+ params["environment"] = environment
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ self._deployment_select()
|
|
|
+ + where
|
|
|
+ + "ORDER BY d.updated_at DESC, d.id"
|
|
|
+ ),
|
|
|
+ params,
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ items = [self._deployment_result(row) for row in rows]
|
|
|
+ if not items:
|
|
|
+ return items
|
|
|
+ deployment_ids = [item["id"] for item in items]
|
|
|
+ transitions = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT id::text AS id, deployment_id::text AS deployment_id, "
|
|
|
+ "from_status, to_status, reason, detail, created_at "
|
|
|
+ "FROM public.dataflow_deployment_transitions "
|
|
|
+ "WHERE deployment_id = ANY(CAST(:ids AS uuid[])) "
|
|
|
+ "ORDER BY created_at DESC, id DESC"
|
|
|
+ ),
|
|
|
+ {"ids": deployment_ids},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ unknown = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT deployment_id::text AS deployment_id, action, "
|
|
|
+ "idempotency_key, error_code, updated_at "
|
|
|
+ "FROM public.dataflow_deployment_operations "
|
|
|
+ "WHERE deployment_id = ANY(CAST(:ids AS uuid[])) "
|
|
|
+ "AND status = 'unknown' ORDER BY updated_at DESC"
|
|
|
+ ),
|
|
|
+ {"ids": deployment_ids},
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ by_transition = {deployment_id: [] for deployment_id in deployment_ids}
|
|
|
+ for row in transitions:
|
|
|
+ event = dict(row)
|
|
|
+ event["id"] = str(event["id"])
|
|
|
+ event["deployment_id"] = str(event["deployment_id"])
|
|
|
+ event["detail"] = _object(event["detail"], "transition detail")
|
|
|
+ if hasattr(event["created_at"], "isoformat"):
|
|
|
+ event["created_at"] = event["created_at"].isoformat()
|
|
|
+ by_transition[event["deployment_id"]].append(event)
|
|
|
+ by_unknown = {deployment_id: [] for deployment_id in deployment_ids}
|
|
|
+ for row in unknown:
|
|
|
+ value = dict(row)
|
|
|
+ value["deployment_id"] = str(value["deployment_id"])
|
|
|
+ if hasattr(value["updated_at"], "isoformat"):
|
|
|
+ value["updated_at"] = value["updated_at"].isoformat()
|
|
|
+ by_unknown[value["deployment_id"]].append(value)
|
|
|
+ active_by_scope = {
|
|
|
+ (item["dataflow_uid"], item["environment"]): item
|
|
|
+ for item in items
|
|
|
+ if item["status"] == "active"
|
|
|
+ }
|
|
|
+ by_id = {item["id"]: item for item in items}
|
|
|
+ for item in items:
|
|
|
+ item["transition_history"] = by_transition[item["id"]]
|
|
|
+ item["unknown_operations"] = by_unknown[item["id"]]
|
|
|
+ active = active_by_scope.get((item["dataflow_uid"], item["environment"]))
|
|
|
+ item["current_active"] = (
|
|
|
+ {
|
|
|
+ "id": active["id"],
|
|
|
+ "version_no": active["version_no"],
|
|
|
+ "package_hash": active["package_hash"],
|
|
|
+ }
|
|
|
+ if active
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ previous = by_id.get(item.get("previous_active_deployment_id"))
|
|
|
+ item["previous_active"] = (
|
|
|
+ {
|
|
|
+ "id": previous["id"],
|
|
|
+ "version_no": previous["version_no"],
|
|
|
+ "package_hash": previous["package_hash"],
|
|
|
+ "binding_hash": previous["binding_hash"],
|
|
|
+ }
|
|
|
+ if previous
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ return items
|
|
|
+
|
|
|
+ def list_deployable_releases(
|
|
|
+ self, *, environment: str, limit: int = 50
|
|
|
+ ) -> list[dict[str, Any]]:
|
|
|
+ if environment not in {"development", "test", "production"}:
|
|
|
+ raise ValueError("deployment environment is invalid")
|
|
|
+ bounded_limit = int(limit)
|
|
|
+ if bounded_limit < 1 or bounded_limit > 100:
|
|
|
+ raise ValueError("candidate limit is invalid")
|
|
|
+ rows = (
|
|
|
+ self.session.execute(
|
|
|
+ text(
|
|
|
+ "SELECT DISTINCT v.id::text AS id "
|
|
|
+ "FROM public.dataflow_versions v "
|
|
|
+ "JOIN public.dataflow_deployments d "
|
|
|
+ "ON d.dataflow_version_id = v.id "
|
|
|
+ "WHERE v.status = 'released' AND v.package IS NOT NULL "
|
|
|
+ "AND d.environment = :environment AND d.package IS NULL "
|
|
|
+ "ORDER BY v.id DESC LIMIT :limit"
|
|
|
+ ),
|
|
|
+ {"environment": environment, "limit": bounded_limit},
|
|
|
+ )
|
|
|
+ .scalars()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ candidates = []
|
|
|
+ for version_id in rows:
|
|
|
+ try:
|
|
|
+ candidate = self.load_deployable_release(
|
|
|
+ str(version_id), environment=environment
|
|
|
+ )
|
|
|
+ except ValueError:
|
|
|
+ continue
|
|
|
+ candidates.append(candidate)
|
|
|
+ return candidates
|